@librechat/agents 3.1.77-dev.1 → 3.1.78-dev.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/dist/cjs/common/enum.cjs +54 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +148 -4
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +317 -1
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +90 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
- package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +27 -0
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/messages/recency.cjs +99 -0
- package/dist/cjs/messages/recency.cjs.map +1 -0
- package/dist/cjs/run.cjs +30 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +100 -6
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +635 -23
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
- package/dist/cjs/tools/local/attachments.cjs +183 -0
- package/dist/cjs/tools/local/attachments.cjs.map +1 -0
- package/dist/cjs/tools/local/bashAst.cjs +129 -0
- package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
- package/dist/cjs/tools/local/editStrategies.cjs +188 -0
- package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
- package/dist/cjs/tools/local/textEncoding.cjs +30 -0
- package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
- package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
- package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +53 -1
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +149 -5
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +318 -2
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +17 -2
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/anthropicToolCache.mjs +99 -0
- package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +26 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/messages/recency.mjs +97 -0
- package/dist/esm/messages/recency.mjs.map +1 -0
- package/dist/esm/run.mjs +30 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +100 -6
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +635 -23
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
- package/dist/esm/tools/local/attachments.mjs +180 -0
- package/dist/esm/tools/local/attachments.mjs.map +1 -0
- package/dist/esm/tools/local/bashAst.mjs +126 -0
- package/dist/esm/tools/local/bashAst.mjs.map +1 -0
- package/dist/esm/tools/local/editStrategies.mjs +185 -0
- package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
- package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
- package/dist/esm/tools/local/textEncoding.mjs +27 -0
- package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
- package/dist/esm/tools/local/workspaceFS.mjs +49 -0
- package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +39 -1
- package/dist/types/graphs/Graph.d.ts +34 -0
- package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
- package/dist/types/hooks/index.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/llm/openai/index.d.ts +17 -0
- package/dist/types/messages/anthropicToolCache.d.ts +51 -0
- package/dist/types/messages/index.d.ts +2 -0
- package/dist/types/messages/prune.d.ts +11 -0
- package/dist/types/messages/recency.d.ts +64 -0
- package/dist/types/run.d.ts +21 -0
- package/dist/types/tools/ToolNode.d.ts +145 -2
- package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
- package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
- package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
- package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
- package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
- package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
- package/dist/types/tools/local/attachments.d.ts +84 -0
- package/dist/types/tools/local/bashAst.d.ts +11 -0
- package/dist/types/tools/local/editStrategies.d.ts +28 -0
- package/dist/types/tools/local/index.d.ts +12 -0
- package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
- package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
- package/dist/types/tools/local/textEncoding.d.ts +21 -0
- package/dist/types/tools/local/workspaceFS.d.ts +49 -0
- package/dist/types/types/hitl.d.ts +56 -27
- package/dist/types/types/run.d.ts +8 -1
- package/dist/types/types/summarize.d.ts +30 -0
- package/dist/types/types/tools.d.ts +341 -6
- package/package.json +21 -2
- package/src/common/enum.ts +54 -0
- package/src/graphs/Graph.ts +164 -6
- package/src/hooks/__tests__/compactHooks.test.ts +38 -2
- package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
- package/src/hooks/createWorkspacePolicyHook.ts +355 -0
- package/src/hooks/index.ts +6 -0
- package/src/index.ts +1 -0
- package/src/llm/openai/deepseek.test.ts +479 -0
- package/src/llm/openai/index.ts +484 -1
- package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
- package/src/messages/__tests__/recency.test.ts +267 -0
- package/src/messages/anthropicToolCache.ts +116 -0
- package/src/messages/index.ts +2 -0
- package/src/messages/prune.ts +27 -1
- package/src/messages/recency.ts +155 -0
- package/src/run.ts +31 -0
- package/src/scripts/compare_pi_vs_ours.ts +840 -0
- package/src/scripts/local_engine.ts +166 -0
- package/src/scripts/local_engine_checkpointer.ts +205 -0
- package/src/scripts/local_engine_compile.ts +263 -0
- package/src/scripts/local_engine_hooks.ts +226 -0
- package/src/scripts/local_engine_image.ts +201 -0
- package/src/scripts/local_engine_ptc.ts +151 -0
- package/src/scripts/local_engine_workspace.ts +258 -0
- package/src/scripts/summarization-recency.ts +462 -0
- package/src/specs/prune.test.ts +39 -0
- package/src/summarization/__tests__/node.test.ts +499 -3
- package/src/summarization/node.ts +124 -7
- package/src/tools/ToolNode.ts +769 -20
- package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
- package/src/tools/__tests__/directToolHooks.test.ts +411 -0
- package/src/tools/__tests__/localToolNames.test.ts +73 -0
- package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
- package/src/tools/local/CompileCheckTool.ts +278 -0
- package/src/tools/local/FileCheckpointer.ts +93 -0
- package/src/tools/local/LocalCodingTools.ts +1342 -0
- package/src/tools/local/LocalExecutionEngine.ts +1329 -0
- package/src/tools/local/LocalExecutionTools.ts +167 -0
- package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
- package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
- package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
- package/src/tools/local/attachments.ts +251 -0
- package/src/tools/local/bashAst.ts +151 -0
- package/src/tools/local/editStrategies.ts +188 -0
- package/src/tools/local/index.ts +12 -0
- package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
- package/src/tools/local/syntaxCheck.ts +243 -0
- package/src/tools/local/textEncoding.ts +37 -0
- package/src/tools/local/workspaceFS.ts +89 -0
- package/src/types/hitl.ts +56 -27
- package/src/types/run.ts +12 -1
- package/src/types/summarize.ts +31 -0
- package/src/types/tools.ts +359 -7
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-occurrence string-replacement strategies for `edit_file`.
|
|
3
|
+
*
|
|
4
|
+
* The LLM frequently emits an `oldString` whose whitespace, indentation,
|
|
5
|
+
* or escape sequences are slightly off from the on-disk content. Rather
|
|
6
|
+
* than failing the call (which forces a re-read + retry round-trip),
|
|
7
|
+
* we walk a chain of progressively looser matchers, stopping at the
|
|
8
|
+
* first one that locates exactly one match. The matched on-disk slice
|
|
9
|
+
* is then literally replaced with `newString` — we never modify
|
|
10
|
+
* `newString`, only the search.
|
|
11
|
+
*
|
|
12
|
+
* Strategies are ordered from strict to lenient so we don't accidentally
|
|
13
|
+
* over-match a more specific pattern with a looser one. Inspired by
|
|
14
|
+
* opencode's nine-strategy chain (sst/opencode), trimmed to the four
|
|
15
|
+
* highest-yield strategies for a first cut. Add more (block-anchor +
|
|
16
|
+
* Levenshtein, escape-normalized, etc.) as needed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface EditMatch {
|
|
20
|
+
/** Strategy name that produced the match, for telemetry/diagnostics. */
|
|
21
|
+
strategy: string;
|
|
22
|
+
/** Starting offset in the source. */
|
|
23
|
+
start: number;
|
|
24
|
+
/** Ending offset (exclusive). */
|
|
25
|
+
end: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type EditStrategy = (
|
|
29
|
+
source: string,
|
|
30
|
+
oldString: string
|
|
31
|
+
) => EditMatch | null;
|
|
32
|
+
|
|
33
|
+
const exactStrategy: EditStrategy = (source, oldString) => {
|
|
34
|
+
if (oldString === '') return null;
|
|
35
|
+
const first = source.indexOf(oldString);
|
|
36
|
+
if (first === -1) return null;
|
|
37
|
+
const second = source.indexOf(oldString, first + oldString.length);
|
|
38
|
+
if (second !== -1) return null;
|
|
39
|
+
return { strategy: 'exact', start: first, end: first + oldString.length };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Match per-line, ignoring trailing whitespace differences. Useful for
|
|
44
|
+
* the very common case where the LLM stripped trailing spaces or added
|
|
45
|
+
* an extra blank.
|
|
46
|
+
*/
|
|
47
|
+
const lineTrimmedStrategy: EditStrategy = (source, oldString) => {
|
|
48
|
+
if (oldString === '') return null;
|
|
49
|
+
const sourceLines = source.split('\n');
|
|
50
|
+
const oldLines = oldString.split('\n');
|
|
51
|
+
if (oldLines.length === 0 || oldLines.length > sourceLines.length) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let foundAt = -1;
|
|
56
|
+
for (let i = 0; i <= sourceLines.length - oldLines.length; i++) {
|
|
57
|
+
let ok = true;
|
|
58
|
+
for (let j = 0; j < oldLines.length; j++) {
|
|
59
|
+
if (sourceLines[i + j].trimEnd() !== oldLines[j].trimEnd()) {
|
|
60
|
+
ok = false;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!ok) continue;
|
|
65
|
+
if (foundAt !== -1) return null; // multiple matches
|
|
66
|
+
foundAt = i;
|
|
67
|
+
}
|
|
68
|
+
if (foundAt === -1) return null;
|
|
69
|
+
|
|
70
|
+
let start = 0;
|
|
71
|
+
for (let i = 0; i < foundAt; i++) start += sourceLines[i].length + 1;
|
|
72
|
+
let end = start;
|
|
73
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
74
|
+
end += sourceLines[foundAt + i].length;
|
|
75
|
+
if (i < oldLines.length - 1) end += 1;
|
|
76
|
+
}
|
|
77
|
+
return { strategy: 'line-trimmed', start, end };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Collapse all runs of whitespace to a single space and match. Catches
|
|
82
|
+
* cases where the LLM normalised tabs to spaces or vice-versa.
|
|
83
|
+
*/
|
|
84
|
+
const whitespaceNormalizedStrategy: EditStrategy = (source, oldString) => {
|
|
85
|
+
if (oldString === '') return null;
|
|
86
|
+
const norm = (s: string): string => s.replace(/\s+/g, ' ').trim();
|
|
87
|
+
const normalizedNeedle = norm(oldString);
|
|
88
|
+
if (normalizedNeedle === '') return null;
|
|
89
|
+
|
|
90
|
+
const sourceLines = source.split('\n');
|
|
91
|
+
const needleLines = oldString.split('\n');
|
|
92
|
+
if (needleLines.length > sourceLines.length) return null;
|
|
93
|
+
|
|
94
|
+
let foundAt = -1;
|
|
95
|
+
for (let i = 0; i <= sourceLines.length - needleLines.length; i++) {
|
|
96
|
+
const candidate = sourceLines
|
|
97
|
+
.slice(i, i + needleLines.length)
|
|
98
|
+
.join('\n');
|
|
99
|
+
if (norm(candidate) !== normalizedNeedle) continue;
|
|
100
|
+
if (foundAt !== -1) return null;
|
|
101
|
+
foundAt = i;
|
|
102
|
+
}
|
|
103
|
+
if (foundAt === -1) return null;
|
|
104
|
+
|
|
105
|
+
let start = 0;
|
|
106
|
+
for (let i = 0; i < foundAt; i++) start += sourceLines[i].length + 1;
|
|
107
|
+
let end = start;
|
|
108
|
+
for (let i = 0; i < needleLines.length; i++) {
|
|
109
|
+
end += sourceLines[foundAt + i].length;
|
|
110
|
+
if (i < needleLines.length - 1) end += 1;
|
|
111
|
+
}
|
|
112
|
+
return { strategy: 'whitespace-normalized', start, end };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Strip the common leading-indent from each line of the needle and
|
|
117
|
+
* each candidate window of the source. Catches the very common case
|
|
118
|
+
* where the LLM omitted the indentation it should have copied.
|
|
119
|
+
*/
|
|
120
|
+
const indentationFlexibleStrategy: EditStrategy = (source, oldString) => {
|
|
121
|
+
if (oldString === '') return null;
|
|
122
|
+
|
|
123
|
+
const stripCommonIndent = (block: string): string => {
|
|
124
|
+
const lines = block.split('\n');
|
|
125
|
+
let common = Number.POSITIVE_INFINITY;
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
if (line.trim() === '') continue;
|
|
128
|
+
const m = /^(\s*)/.exec(line);
|
|
129
|
+
const indent = m ? m[1].length : 0;
|
|
130
|
+
if (indent < common) common = indent;
|
|
131
|
+
if (common === 0) break;
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isFinite(common) || common === 0) return block;
|
|
134
|
+
return lines
|
|
135
|
+
.map((l) => (l.length >= common ? l.slice(common) : l))
|
|
136
|
+
.join('\n');
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const normalizedNeedle = stripCommonIndent(oldString);
|
|
140
|
+
if (normalizedNeedle === '') return null;
|
|
141
|
+
|
|
142
|
+
const sourceLines = source.split('\n');
|
|
143
|
+
const needleLines = oldString.split('\n');
|
|
144
|
+
if (needleLines.length > sourceLines.length) return null;
|
|
145
|
+
|
|
146
|
+
let foundAt = -1;
|
|
147
|
+
for (let i = 0; i <= sourceLines.length - needleLines.length; i++) {
|
|
148
|
+
const window = sourceLines.slice(i, i + needleLines.length).join('\n');
|
|
149
|
+
if (stripCommonIndent(window) !== normalizedNeedle) continue;
|
|
150
|
+
if (foundAt !== -1) return null;
|
|
151
|
+
foundAt = i;
|
|
152
|
+
}
|
|
153
|
+
if (foundAt === -1) return null;
|
|
154
|
+
|
|
155
|
+
let start = 0;
|
|
156
|
+
for (let i = 0; i < foundAt; i++) start += sourceLines[i].length + 1;
|
|
157
|
+
let end = start;
|
|
158
|
+
for (let i = 0; i < needleLines.length; i++) {
|
|
159
|
+
end += sourceLines[foundAt + i].length;
|
|
160
|
+
if (i < needleLines.length - 1) end += 1;
|
|
161
|
+
}
|
|
162
|
+
return { strategy: 'indentation-flexible', start, end };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const STRATEGY_CHAIN: EditStrategy[] = [
|
|
166
|
+
exactStrategy,
|
|
167
|
+
lineTrimmedStrategy,
|
|
168
|
+
whitespaceNormalizedStrategy,
|
|
169
|
+
indentationFlexibleStrategy,
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
export function locateEdit(source: string, oldString: string): EditMatch | null {
|
|
173
|
+
for (const strategy of STRATEGY_CHAIN) {
|
|
174
|
+
const match = strategy(source, oldString);
|
|
175
|
+
if (match != null) {
|
|
176
|
+
return match;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function applyEdit(
|
|
183
|
+
source: string,
|
|
184
|
+
match: EditMatch,
|
|
185
|
+
newString: string
|
|
186
|
+
): string {
|
|
187
|
+
return source.slice(0, match.start) + newString + source.slice(match.end);
|
|
188
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './CompileCheckTool';
|
|
2
|
+
export * from './FileCheckpointer';
|
|
3
|
+
export * from './LocalCodingTools';
|
|
4
|
+
export * from './LocalExecutionEngine';
|
|
5
|
+
export * from './LocalExecutionTools';
|
|
6
|
+
export * from './LocalProgrammaticToolCalling';
|
|
7
|
+
export * from './resolveLocalExecutionTools';
|
|
8
|
+
export * from './attachments';
|
|
9
|
+
export * from './bashAst';
|
|
10
|
+
export * from './editStrategies';
|
|
11
|
+
export * from './syntaxCheck';
|
|
12
|
+
export * from './textEncoding';
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Constants, CODE_EXECUTION_TOOLS } from '@/common';
|
|
2
|
+
import {
|
|
3
|
+
createLocalBashExecutionTool,
|
|
4
|
+
createLocalCodeExecutionTool,
|
|
5
|
+
} from './LocalExecutionTools';
|
|
6
|
+
import {
|
|
7
|
+
createLocalCodingToolBundle,
|
|
8
|
+
createLocalCodingToolDefinitions,
|
|
9
|
+
createLocalCodingTools,
|
|
10
|
+
} from './LocalCodingTools';
|
|
11
|
+
import {
|
|
12
|
+
createLocalBashProgrammaticToolCallingTool,
|
|
13
|
+
createLocalProgrammaticToolCallingTool,
|
|
14
|
+
} from './LocalProgrammaticToolCalling';
|
|
15
|
+
import type * as t from '@/types';
|
|
16
|
+
|
|
17
|
+
type ResolveLocalToolsResult = {
|
|
18
|
+
toolMap: t.ToolMap;
|
|
19
|
+
directToolNames: Set<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Set when `local.fileCheckpointing === true` AND the auto-bind
|
|
22
|
+
* coding suite is in use. ToolNode stashes this on the node and
|
|
23
|
+
* exposes it via `getFileCheckpointer()` so the host can call
|
|
24
|
+
* `rewind()` after a failed batch. Manual review (finding E)
|
|
25
|
+
* flagged that the config flag was previously a no-op in the
|
|
26
|
+
* Run/ToolNode auto-bind path — only direct
|
|
27
|
+
* `createLocalCodingToolBundle()` callers could access the
|
|
28
|
+
* checkpointer.
|
|
29
|
+
*/
|
|
30
|
+
fileCheckpointer?: t.LocalFileCheckpointer;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function shouldUseLocalExecution(config?: t.ToolExecutionConfig): boolean {
|
|
34
|
+
return config?.engine === 'local';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shouldIncludeCodingTools(config?: t.ToolExecutionConfig): boolean {
|
|
38
|
+
return (
|
|
39
|
+
shouldUseLocalExecution(config) &&
|
|
40
|
+
config?.local?.includeCodingTools !== false
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createLocalExecutionTool(
|
|
45
|
+
name: string,
|
|
46
|
+
config: t.LocalExecutionConfig
|
|
47
|
+
): t.GenericTool | undefined {
|
|
48
|
+
switch (name) {
|
|
49
|
+
case Constants.EXECUTE_CODE:
|
|
50
|
+
return createLocalCodeExecutionTool(config);
|
|
51
|
+
case Constants.BASH_TOOL:
|
|
52
|
+
return createLocalBashExecutionTool({ config });
|
|
53
|
+
case Constants.PROGRAMMATIC_TOOL_CALLING:
|
|
54
|
+
return createLocalProgrammaticToolCallingTool(config);
|
|
55
|
+
case Constants.BASH_PROGRAMMATIC_TOOL_CALLING:
|
|
56
|
+
return createLocalBashProgrammaticToolCallingTool(config);
|
|
57
|
+
default:
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mergeToolsByName(
|
|
63
|
+
baseTools: t.GraphTools | undefined,
|
|
64
|
+
localTools: t.GenericTool[]
|
|
65
|
+
): t.GraphTools {
|
|
66
|
+
const orderedTools: t.GenericTool[] = [];
|
|
67
|
+
const indexByName = new Map<string, number>();
|
|
68
|
+
|
|
69
|
+
for (const tool of (baseTools as t.GenericTool[] | undefined) ?? []) {
|
|
70
|
+
if ('name' in tool && typeof tool.name === 'string') {
|
|
71
|
+
indexByName.set(tool.name, orderedTools.length);
|
|
72
|
+
}
|
|
73
|
+
orderedTools.push(tool);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const tool of localTools) {
|
|
77
|
+
const existingIndex = indexByName.get(tool.name);
|
|
78
|
+
if (existingIndex == null) {
|
|
79
|
+
indexByName.set(tool.name, orderedTools.length);
|
|
80
|
+
orderedTools.push(tool);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
orderedTools[existingIndex] = tool;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return orderedTools;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveLocalToolsForBinding(args: {
|
|
90
|
+
tools?: t.GraphTools;
|
|
91
|
+
toolExecution?: t.ToolExecutionConfig;
|
|
92
|
+
}): t.GraphTools | undefined {
|
|
93
|
+
if (!shouldUseLocalExecution(args.toolExecution)) {
|
|
94
|
+
return args.tools;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const localConfig = args.toolExecution?.local ?? {};
|
|
98
|
+
if (shouldIncludeCodingTools(args.toolExecution)) {
|
|
99
|
+
return mergeToolsByName(args.tools, createLocalCodingTools(localConfig));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const replacements = ((args.tools as t.GenericTool[] | undefined) ?? [])
|
|
103
|
+
.filter(
|
|
104
|
+
(existingTool): existingTool is t.GenericTool & { name: string } =>
|
|
105
|
+
'name' in existingTool &&
|
|
106
|
+
typeof existingTool.name === 'string' &&
|
|
107
|
+
CODE_EXECUTION_TOOLS.has(existingTool.name)
|
|
108
|
+
)
|
|
109
|
+
.map((existingTool) =>
|
|
110
|
+
createLocalExecutionTool(existingTool.name, localConfig)
|
|
111
|
+
)
|
|
112
|
+
.filter((localTool): localTool is t.GenericTool => localTool != null);
|
|
113
|
+
|
|
114
|
+
return replacements.length === 0
|
|
115
|
+
? args.tools
|
|
116
|
+
: mergeToolsByName(args.tools, replacements);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function resolveLocalToolRegistry(args: {
|
|
120
|
+
toolRegistry?: t.LCToolRegistry;
|
|
121
|
+
toolExecution?: t.ToolExecutionConfig;
|
|
122
|
+
}): t.LCToolRegistry | undefined {
|
|
123
|
+
if (!shouldIncludeCodingTools(args.toolExecution)) {
|
|
124
|
+
return args.toolRegistry;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const registry = new Map(args.toolRegistry ?? []);
|
|
128
|
+
for (const definition of createLocalCodingToolDefinitions()) {
|
|
129
|
+
registry.set(definition.name, definition);
|
|
130
|
+
}
|
|
131
|
+
return registry;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function resolveLocalExecutionTools(args: {
|
|
135
|
+
toolMap: t.ToolMap;
|
|
136
|
+
toolExecution?: t.ToolExecutionConfig;
|
|
137
|
+
/**
|
|
138
|
+
* Caller-provided checkpointer that overrides the bundle's
|
|
139
|
+
* auto-created one. The Graph layer threads a single per-Run
|
|
140
|
+
* instance so every ToolNode it compiles shares one snapshot
|
|
141
|
+
* store — without that, a multi-agent graph would each get a
|
|
142
|
+
* private checkpointer and `Run.rewindFiles()` couldn't reach
|
|
143
|
+
* any of them.
|
|
144
|
+
*/
|
|
145
|
+
fileCheckpointer?: t.LocalFileCheckpointer;
|
|
146
|
+
}): ResolveLocalToolsResult {
|
|
147
|
+
const directToolNames = new Set<string>();
|
|
148
|
+
if (!shouldUseLocalExecution(args.toolExecution)) {
|
|
149
|
+
return {
|
|
150
|
+
toolMap: args.toolMap,
|
|
151
|
+
directToolNames,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const localConfig = args.toolExecution?.local ?? {};
|
|
156
|
+
const toolMap = new Map(args.toolMap);
|
|
157
|
+
let fileCheckpointer: t.LocalFileCheckpointer | undefined;
|
|
158
|
+
|
|
159
|
+
if (shouldIncludeCodingTools(args.toolExecution)) {
|
|
160
|
+
// Use the bundle factory when fileCheckpointing is on so we can
|
|
161
|
+
// surface the checkpointer back to the caller — without this, the
|
|
162
|
+
// execution-path tools each captured into a checkpointer that was
|
|
163
|
+
// immediately discarded, making the public `fileCheckpointing`
|
|
164
|
+
// config flag a silent no-op outside of direct
|
|
165
|
+
// `createLocalCodingToolBundle()` use.
|
|
166
|
+
if (localConfig.fileCheckpointing === true || args.fileCheckpointer != null) {
|
|
167
|
+
const bundle = createLocalCodingToolBundle(localConfig, {
|
|
168
|
+
checkpointer: args.fileCheckpointer,
|
|
169
|
+
});
|
|
170
|
+
fileCheckpointer = bundle.checkpointer;
|
|
171
|
+
for (const localTool of bundle.tools) {
|
|
172
|
+
toolMap.set(localTool.name, localTool);
|
|
173
|
+
directToolNames.add(localTool.name);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
for (const localTool of createLocalCodingTools(localConfig)) {
|
|
177
|
+
toolMap.set(localTool.name, localTool);
|
|
178
|
+
directToolNames.add(localTool.name);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// When the coding-tool bundle was already installed above, it
|
|
184
|
+
// already created `bash_tool` / `execute_code` / programmatic-tool
|
|
185
|
+
// variants. Skip re-creating them here — the audit-of-audit (manual
|
|
186
|
+
// finding #4) flagged that the original loop overwrote those bundle
|
|
187
|
+
// instances with fresh ones via `createLocalExecutionTool`, wasting
|
|
188
|
+
// work and (more importantly) replacing tools the bundle had
|
|
189
|
+
// already wired up with shared state. The CODE_EXECUTION_TOOLS
|
|
190
|
+
// loop is now only relevant when the host pre-bound a tool with
|
|
191
|
+
// one of these names (the `toolMap.has(name)` branch) and coding
|
|
192
|
+
// tools are off.
|
|
193
|
+
const includeCodingTools = shouldIncludeCodingTools(args.toolExecution);
|
|
194
|
+
for (const name of CODE_EXECUTION_TOOLS) {
|
|
195
|
+
if (includeCodingTools) continue;
|
|
196
|
+
if (!toolMap.has(name)) continue;
|
|
197
|
+
|
|
198
|
+
const localTool = createLocalExecutionTool(name, localConfig);
|
|
199
|
+
if (localTool == null) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
toolMap.set(name, localTool);
|
|
204
|
+
directToolNames.add(name);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { toolMap, directToolNames, fileCheckpointer };
|
|
208
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-file syntax check used by `edit_file` / `write_file` to surface
|
|
3
|
+
* obvious errors immediately after the write — strictly cheaper than
|
|
4
|
+
* full LSP integration and catches the bulk of "you broke the file"
|
|
5
|
+
* regressions a vision-less agent loop would otherwise miss until
|
|
6
|
+
* the next call.
|
|
7
|
+
*
|
|
8
|
+
* Each checker is a tiny shell-out (or in-process function) keyed on
|
|
9
|
+
* file extension. Failures are returned as a single short message;
|
|
10
|
+
* the wiring layer decides whether to append it to the tool result
|
|
11
|
+
* advisorily (`auto`) or to throw and force the model to react
|
|
12
|
+
* (`strict`).
|
|
13
|
+
*
|
|
14
|
+
* We deliberately do NOT cover TypeScript here because per-file `tsc`
|
|
15
|
+
* is slow and per-file syntax (without type info) misses most TS
|
|
16
|
+
* errors anyway. Use the project-level `compile_check` tool for that.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { extname } from 'path';
|
|
20
|
+
import type * as t from '@/types';
|
|
21
|
+
import {
|
|
22
|
+
getSpawn,
|
|
23
|
+
getWorkspaceFS,
|
|
24
|
+
spawnLocalProcess,
|
|
25
|
+
} from './LocalExecutionEngine';
|
|
26
|
+
|
|
27
|
+
export type SyntaxCheckOutcome =
|
|
28
|
+
| { ok: true }
|
|
29
|
+
| { ok: false; checker: string; output: string };
|
|
30
|
+
|
|
31
|
+
export type SyntaxChecker = (
|
|
32
|
+
path: string,
|
|
33
|
+
config: t.LocalExecutionConfig
|
|
34
|
+
) => Promise<SyntaxCheckOutcome>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-backend availability cache for the post-edit syntax-check probe
|
|
38
|
+
* tools (node, python3, bash). Keyed on the *effective spawn backend*
|
|
39
|
+
* — see `getSpawn(config)` in LocalExecutionEngine — so a Run that
|
|
40
|
+
* probes node over Node's child_process can't poison a subsequent Run
|
|
41
|
+
* whose `local.exec.spawn` routes elsewhere (a remote sandbox might
|
|
42
|
+
* have python but not node, etc.).
|
|
43
|
+
*
|
|
44
|
+
* Mirrors the same fix that landed for the ripgrep cache in
|
|
45
|
+
* `LocalCodingTools.ts` after the first round of Codex review.
|
|
46
|
+
* WeakMap keying lets disposed backends GC their entry; the test
|
|
47
|
+
* reset hook re-creates the map.
|
|
48
|
+
*/
|
|
49
|
+
type ProbeKind = 'hasNode' | 'hasPython' | 'hasBash';
|
|
50
|
+
type ProbeCache = Partial<Record<ProbeKind, Promise<boolean>>>;
|
|
51
|
+
|
|
52
|
+
// Per-backend × per-env cache. Codex P2 #40 — keying by spawn
|
|
53
|
+
// backend alone misses env-driven availability changes (e.g. PATH
|
|
54
|
+
// loses node between Runs that share the same backend). Same fix
|
|
55
|
+
// shape as the ripgrep cache (Codex P1 #34).
|
|
56
|
+
let probeCacheByBackend = new WeakMap<
|
|
57
|
+
t.LocalSpawn,
|
|
58
|
+
Map<string, ProbeCache>
|
|
59
|
+
>();
|
|
60
|
+
|
|
61
|
+
function envCacheKey(env: NodeJS.ProcessEnv | undefined): string {
|
|
62
|
+
if (env == null) return '';
|
|
63
|
+
const sorted: Record<string, string | undefined> = {};
|
|
64
|
+
for (const k of Object.keys(env).sort()) {
|
|
65
|
+
sorted[k] = env[k];
|
|
66
|
+
}
|
|
67
|
+
return JSON.stringify(sorted);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cacheFor(
|
|
71
|
+
config: t.LocalExecutionConfig
|
|
72
|
+
): ProbeCache {
|
|
73
|
+
const backend = getSpawn(config);
|
|
74
|
+
let envMap = probeCacheByBackend.get(backend);
|
|
75
|
+
if (envMap == null) {
|
|
76
|
+
envMap = new Map();
|
|
77
|
+
probeCacheByBackend.set(backend, envMap);
|
|
78
|
+
}
|
|
79
|
+
const envKey = envCacheKey(config.env);
|
|
80
|
+
let entry = envMap.get(envKey);
|
|
81
|
+
if (entry == null) {
|
|
82
|
+
entry = {};
|
|
83
|
+
envMap.set(envKey, entry);
|
|
84
|
+
}
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function probe(
|
|
89
|
+
command: string,
|
|
90
|
+
args: string[],
|
|
91
|
+
cached: ProbeKind,
|
|
92
|
+
config: t.LocalExecutionConfig
|
|
93
|
+
): Promise<boolean> {
|
|
94
|
+
const entry = cacheFor(config);
|
|
95
|
+
let probePromise = entry[cached];
|
|
96
|
+
if (probePromise == null) {
|
|
97
|
+
probePromise = spawnLocalProcess(
|
|
98
|
+
command,
|
|
99
|
+
args,
|
|
100
|
+
{ ...config, timeoutMs: 5000, sandbox: { enabled: false } },
|
|
101
|
+
{ internal: true }
|
|
102
|
+
)
|
|
103
|
+
.then((result) => result != null && result.exitCode === 0)
|
|
104
|
+
.catch(() => false);
|
|
105
|
+
entry[cached] = probePromise;
|
|
106
|
+
}
|
|
107
|
+
return probePromise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Test-only reset hook. Clears the per-backend probe cache so tests
|
|
112
|
+
* can swap in mocked spawn backends and reprobe deterministically.
|
|
113
|
+
*
|
|
114
|
+
* @internal Not part of the public SDK surface.
|
|
115
|
+
*/
|
|
116
|
+
export function _resetSyntaxCheckProbeCacheForTests(): void {
|
|
117
|
+
probeCacheByBackend = new WeakMap();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const jsCheck: SyntaxChecker = async (path, config) => {
|
|
121
|
+
if (!(await probe('node', ['--version'], 'hasNode', config))) {
|
|
122
|
+
return { ok: true };
|
|
123
|
+
}
|
|
124
|
+
const result = await spawnLocalProcess(
|
|
125
|
+
'node',
|
|
126
|
+
['--check', path],
|
|
127
|
+
{ ...config, timeoutMs: 5000, sandbox: { enabled: false } },
|
|
128
|
+
{ internal: true }
|
|
129
|
+
);
|
|
130
|
+
if (result.exitCode === 0) return { ok: true };
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
checker: 'node --check',
|
|
134
|
+
output: result.stderr.trim() || result.stdout.trim() || 'syntax error',
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const pythonCheck: SyntaxChecker = async (path, config) => {
|
|
139
|
+
if (!(await probe('python3', ['--version'], 'hasPython', config))) {
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
142
|
+
const program =
|
|
143
|
+
'import py_compile, sys\n' +
|
|
144
|
+
'try:\n' +
|
|
145
|
+
' py_compile.compile(sys.argv[1], doraise=True)\n' +
|
|
146
|
+
'except py_compile.PyCompileError as e:\n' +
|
|
147
|
+
' print(e.msg.strip(), file=sys.stderr)\n' +
|
|
148
|
+
' sys.exit(1)\n';
|
|
149
|
+
const result = await spawnLocalProcess(
|
|
150
|
+
'python3',
|
|
151
|
+
['-c', program, path],
|
|
152
|
+
{ ...config, timeoutMs: 5000, sandbox: { enabled: false } },
|
|
153
|
+
{ internal: true }
|
|
154
|
+
);
|
|
155
|
+
if (result.exitCode === 0) return { ok: true };
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
checker: 'py_compile',
|
|
159
|
+
output: result.stderr.trim() || result.stdout.trim() || 'syntax error',
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const jsonCheck: SyntaxChecker = async (path, config) => {
|
|
164
|
+
// Route through the configured WorkspaceFS so a Run with a custom
|
|
165
|
+
// `local.exec.fs` (in-memory or remote engine) validates the same
|
|
166
|
+
// file the write_file/edit_file path actually wrote — pre-fix this
|
|
167
|
+
// read went to the host fs and either silently passed (no host
|
|
168
|
+
// file → catch returns undefined → ok: true) or read a different
|
|
169
|
+
// file with the same absolute path. Codex P1 #24.
|
|
170
|
+
const fs = getWorkspaceFS(config);
|
|
171
|
+
const raw = await fs.readFile(path, 'utf8').catch(() => undefined);
|
|
172
|
+
if (raw == null) return { ok: true };
|
|
173
|
+
try {
|
|
174
|
+
JSON.parse(raw);
|
|
175
|
+
return { ok: true };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
checker: 'JSON.parse',
|
|
180
|
+
output: (err as Error).message,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const bashCheck: SyntaxChecker = async (path, config) => {
|
|
186
|
+
if (!(await probe('bash', ['--version'], 'hasBash', config))) {
|
|
187
|
+
return { ok: true };
|
|
188
|
+
}
|
|
189
|
+
const result = await spawnLocalProcess(
|
|
190
|
+
'bash',
|
|
191
|
+
['-n', path],
|
|
192
|
+
{ ...config, timeoutMs: 5000, sandbox: { enabled: false } },
|
|
193
|
+
{ internal: true }
|
|
194
|
+
);
|
|
195
|
+
if (result.exitCode === 0) return { ok: true };
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
checker: 'bash -n',
|
|
199
|
+
output: result.stderr.trim() || result.stdout.trim() || 'syntax error',
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const CHECKERS_BY_EXT: Record<string, SyntaxChecker> = {
|
|
204
|
+
'.js': jsCheck,
|
|
205
|
+
'.mjs': jsCheck,
|
|
206
|
+
'.cjs': jsCheck,
|
|
207
|
+
'.jsx': jsCheck,
|
|
208
|
+
'.py': pythonCheck,
|
|
209
|
+
'.pyw': pythonCheck,
|
|
210
|
+
'.json': jsonCheck,
|
|
211
|
+
'.sh': bashCheck,
|
|
212
|
+
'.bash': bashCheck,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Run the post-edit syntax check for `absolutePath`. Returns
|
|
217
|
+
* `null` when no checker matches the extension (most files), or a
|
|
218
|
+
* `SyntaxCheckOutcome`.
|
|
219
|
+
*
|
|
220
|
+
* Truncates `output` to `maxOutputChars` (default 4096) so a
|
|
221
|
+
* 10MB-of-errors transpiler dump can't blow the model context.
|
|
222
|
+
*/
|
|
223
|
+
export async function runPostEditSyntaxCheck(
|
|
224
|
+
absolutePath: string,
|
|
225
|
+
config: t.LocalExecutionConfig
|
|
226
|
+
): Promise<SyntaxCheckOutcome | null> {
|
|
227
|
+
const ext = extname(absolutePath).toLowerCase();
|
|
228
|
+
const checker = (CHECKERS_BY_EXT as Record<string, SyntaxChecker | undefined>)[ext];
|
|
229
|
+
if (checker == null) return null;
|
|
230
|
+
try {
|
|
231
|
+
const result = await checker(absolutePath, config);
|
|
232
|
+
if (!result.ok) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
checker: result.checker,
|
|
236
|
+
output: result.output.slice(0, 4096),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOM and line-ending preservation helpers for the local engine's
|
|
3
|
+
* file-mutating tools. We never *introduce* a BOM or change line
|
|
4
|
+
* endings — only preserve what was already on disk so a Windows-
|
|
5
|
+
* checked-in source file stays CRLF and a UTF-8-with-BOM JSON file
|
|
6
|
+
* keeps its BOM after an edit.
|
|
7
|
+
*
|
|
8
|
+
* Inspired by opencode's `Bom` helper. Trimmed to the cases that
|
|
9
|
+
* actually matter for editing source code (UTF-8 BOM only;
|
|
10
|
+
* UTF-16/UTF-32 are out of scope).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const UTF8_BOM = '';
|
|
14
|
+
|
|
15
|
+
export interface EncodedFile {
|
|
16
|
+
/** File contents with BOM stripped. */
|
|
17
|
+
text: string;
|
|
18
|
+
/** Whether the on-disk file started with a UTF-8 BOM. */
|
|
19
|
+
hasBom: boolean;
|
|
20
|
+
/** Detected newline style. CRLF wins if any CRLF is present. */
|
|
21
|
+
newline: '\n' | '\r\n';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function decodeFile(raw: string): EncodedFile {
|
|
25
|
+
const hasBom = raw.startsWith(UTF8_BOM);
|
|
26
|
+
const stripped = hasBom ? raw.slice(1) : raw;
|
|
27
|
+
const newline = stripped.includes('\r\n') ? '\r\n' : '\n';
|
|
28
|
+
// Internally we always work in LF; encode() restores CRLF on write.
|
|
29
|
+
const lf = newline === '\r\n' ? stripped.replace(/\r\n/g, '\n') : stripped;
|
|
30
|
+
return { text: lf, hasBom, newline };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function encodeFile(text: string, encoding: EncodedFile): string {
|
|
34
|
+
const out =
|
|
35
|
+
encoding.newline === '\r\n' ? text.replace(/\n/g, '\r\n') : text;
|
|
36
|
+
return encoding.hasBom ? `${UTF8_BOM}${out}` : out;
|
|
37
|
+
}
|