patchling 0.2.0 → 0.2.1
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 +53 -8
- package/package.json +6 -4
- package/src/applydiff.d.ts +69 -0
- package/src/applydiff.js +782 -0
- package/src/filter.d.ts +366 -0
- package/src/filter.js +1513 -0
- package/src/gptdiff.d.ts +28 -1
- package/src/gptdiff.js +78 -10
- package/src/index.d.ts +3 -0
- package/src/index.js +25 -0
- package/src/llm.d.ts +4 -1
- package/src/llm.js +4 -0
package/src/gptdiff.d.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rebuild the environment string from a `{ path: content }` map.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in relevance filter: pass `{ decisions }` from `filterRelevant` and
|
|
5
|
+
* only files whose decision is `"yes"` are included. An array of those
|
|
6
|
+
* decisions is also accepted as the second argument. Omit it and every file
|
|
7
|
+
* is included, in insertion order. Every other file stays out of the
|
|
8
|
+
* string that `generateDiff` sees.
|
|
9
|
+
*
|
|
3
10
|
* @param {Record<string, string>} filesDict
|
|
11
|
+
* @param {{ decisions?: Array<{ path?: string, decision?: string }> } | Array<{ path?: string, decision?: string }>} [opts]
|
|
12
|
+
* `{ decisions }` from `filterRelevant`, or that array itself.
|
|
4
13
|
* @returns {string}
|
|
5
14
|
*/
|
|
6
|
-
export function buildEnvironment(filesDict: Record<string, string
|
|
15
|
+
export function buildEnvironment(filesDict: Record<string, string>, opts?: {
|
|
16
|
+
decisions?: Array<{
|
|
17
|
+
path?: string;
|
|
18
|
+
decision?: string;
|
|
19
|
+
}>;
|
|
20
|
+
} | Array<{
|
|
21
|
+
path?: string;
|
|
22
|
+
decision?: string;
|
|
23
|
+
}>): string;
|
|
7
24
|
/**
|
|
8
25
|
* Build the prompt, call the LLM, and extract the unified diff from the
|
|
9
26
|
* ```diff fenced blocks of the response.
|
|
@@ -134,6 +151,16 @@ export function callLlmForApplyWithThink(filePath: string, originalContent: stri
|
|
|
134
151
|
* resolution. Set `opts.forceLlm` to skip the deterministic fast path and send
|
|
135
152
|
* every file to the LLM.
|
|
136
153
|
*
|
|
154
|
+
* When the diff's context lines are not in the file, the model result is
|
|
155
|
+
* checked against the diff's `+`/`-` lines. An indentation shift, or a hunk
|
|
156
|
+
* that matches somewhere other than its `@@` line, still counts as context.
|
|
157
|
+
* A diff name that pairs with a different name already in the file
|
|
158
|
+
* (`user_id` beside `uid`) is rewritten onto the file's name before any
|
|
159
|
+
* model call. A result that copies stale context, deletes any other line,
|
|
160
|
+
* or is empty is discarded. The same edit is then replayed from the `+`/`-`
|
|
161
|
+
* lines (at the file's indent, with those names); if that cannot be done
|
|
162
|
+
* without guessing, this throws instead of writing the file.
|
|
163
|
+
*
|
|
137
164
|
* @param {string} diffText
|
|
138
165
|
* @param {Record<string, string>} files
|
|
139
166
|
* @param {object} [opts]
|
package/src/gptdiff.js
CHANGED
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
* the logic is fully unit-testable without network access.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
parseDiffPerFile,
|
|
11
|
+
applyPatchToFile,
|
|
12
|
+
applyAdaptedIdentifiers,
|
|
13
|
+
applyIntentIgnoringBadContext,
|
|
14
|
+
diffOutputViolatesIntent,
|
|
15
|
+
} from './applydiff.js';
|
|
10
16
|
import {
|
|
11
17
|
swallowReasoning,
|
|
12
18
|
stripBadOutput,
|
|
@@ -33,21 +39,53 @@ a/file.py b/file.py
|
|
|
33
39
|
You must include the '--- file' and/or '+++ file' part of the diff. File modifications should include both.
|
|
34
40
|
`;
|
|
35
41
|
|
|
36
|
-
const APPLY_SYSTEM_PROMPT = `Please apply the diff to this file. Return the
|
|
42
|
+
const APPLY_SYSTEM_PROMPT = `Please apply the diff to this file. Return the full file contents.
|
|
37
43
|
|
|
38
|
-
1.
|
|
39
|
-
2.
|
|
40
|
-
3.
|
|
41
|
-
4.
|
|
44
|
+
1. Apply ONLY the changed lines. A line beginning with '-' is removed and a line beginning with '+' is inserted. Those are the only edits you may make.
|
|
45
|
+
2. Context lines (a leading space, or a bare line that is not '+' or '-') are location hints and are often wrong: wrong whitespace, stale values, or ellipsis ("..."). Never copy a context line from the diff into the file. If a context line does not already match the file exactly, keep the file's original line character for character.
|
|
46
|
+
3. Do not delete, rewrite, reorder, or drop any line the diff does not remove with a '-' line. Lines the diff never mentions, including lines before and after the hunk, must be preserved verbatim.
|
|
47
|
+
4. Only return the final file content. Do not add any additional markup and do not add a code block.
|
|
48
|
+
5. You must return the entire file. It overwrites the existing file.`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decisions from `filterRelevant`, or a bare array of them.
|
|
52
|
+
* `null` means "include every file" (the default).
|
|
53
|
+
* @param {object | Array<{ path?: string, decision?: string }> | undefined} opts
|
|
54
|
+
* @returns {Array<{ path?: string, decision?: string }> | null}
|
|
55
|
+
*/
|
|
56
|
+
function decisionsFrom(opts) {
|
|
57
|
+
if (!opts) return null;
|
|
58
|
+
if (Array.isArray(opts)) return opts;
|
|
59
|
+
if (Array.isArray(opts.decisions)) return opts.decisions;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
42
62
|
|
|
43
63
|
/**
|
|
44
64
|
* Rebuild the environment string from a `{ path: content }` map.
|
|
65
|
+
*
|
|
66
|
+
* Opt-in relevance filter: pass `{ decisions }` from `filterRelevant` and
|
|
67
|
+
* only files whose decision is `"yes"` are included. An array of those
|
|
68
|
+
* decisions is also accepted as the second argument. Omit it and every file
|
|
69
|
+
* is included, in insertion order. Every other file stays out of the
|
|
70
|
+
* string that `generateDiff` sees.
|
|
71
|
+
*
|
|
45
72
|
* @param {Record<string, string>} filesDict
|
|
73
|
+
* @param {{ decisions?: Array<{ path?: string, decision?: string }> } | Array<{ path?: string, decision?: string }>} [opts]
|
|
74
|
+
* `{ decisions }` from `filterRelevant`, or that array itself.
|
|
46
75
|
* @returns {string}
|
|
47
76
|
*/
|
|
48
|
-
export function buildEnvironment(filesDict) {
|
|
77
|
+
export function buildEnvironment(filesDict, opts) {
|
|
78
|
+
let entries = Object.entries(filesDict || {});
|
|
79
|
+
const decisions = decisionsFrom(opts);
|
|
80
|
+
if (decisions) {
|
|
81
|
+
const yes = new Set();
|
|
82
|
+
for (const decision of decisions) {
|
|
83
|
+
if (decision && decision.decision === 'yes' && decision.path) yes.add(decision.path);
|
|
84
|
+
}
|
|
85
|
+
entries = entries.filter(([path]) => yes.has(path));
|
|
86
|
+
}
|
|
49
87
|
const env = [];
|
|
50
|
-
for (const [path, content] of
|
|
88
|
+
for (const [path, content] of entries) {
|
|
51
89
|
env.push(`File: ${path}`);
|
|
52
90
|
env.push('Content:');
|
|
53
91
|
env.push(content);
|
|
@@ -244,6 +282,9 @@ Diff to apply:
|
|
|
244
282
|
\`\`\`diff
|
|
245
283
|
${fileDiff}
|
|
246
284
|
\`\`\``;
|
|
285
|
+
userPrompt += `
|
|
286
|
+
|
|
287
|
+
Apply only the '-' and '+' lines. Context lines may be wrong; never copy them into the file, and do not delete or rewrite any line the diff does not remove.`;
|
|
247
288
|
if (extraPrompt) userPrompt += `\n\n${extraPrompt}`;
|
|
248
289
|
if (model && model.includes('gemini')) {
|
|
249
290
|
userPrompt = APPLY_SYSTEM_PROMPT + '\n' + userPrompt;
|
|
@@ -294,6 +335,16 @@ export async function callLlmForApplyWithThink(filePath, originalContent, fileDi
|
|
|
294
335
|
* resolution. Set `opts.forceLlm` to skip the deterministic fast path and send
|
|
295
336
|
* every file to the LLM.
|
|
296
337
|
*
|
|
338
|
+
* When the diff's context lines are not in the file, the model result is
|
|
339
|
+
* checked against the diff's `+`/`-` lines. An indentation shift, or a hunk
|
|
340
|
+
* that matches somewhere other than its `@@` line, still counts as context.
|
|
341
|
+
* A diff name that pairs with a different name already in the file
|
|
342
|
+
* (`user_id` beside `uid`) is rewritten onto the file's name before any
|
|
343
|
+
* model call. A result that copies stale context, deletes any other line,
|
|
344
|
+
* or is empty is discarded. The same edit is then replayed from the `+`/`-`
|
|
345
|
+
* lines (at the file's indent, with those names); if that cannot be done
|
|
346
|
+
* without guessing, this throws instead of writing the file.
|
|
347
|
+
*
|
|
297
348
|
* @param {string} diffText
|
|
298
349
|
* @param {Record<string, string>} files
|
|
299
350
|
* @param {object} [opts]
|
|
@@ -330,8 +381,12 @@ export async function smartapply(diffText, files, opts = {}) {
|
|
|
330
381
|
return;
|
|
331
382
|
}
|
|
332
383
|
// Fast path: try to apply the patch deterministically, no LLM call.
|
|
384
|
+
// A clean hunk wins first. A rename that the diff's context already
|
|
385
|
+
// reveals (`user_id` in the hunk, `uid` in the file) is applied from
|
|
386
|
+
// the +/- lines alone, so the model cannot leave the diff's name behind.
|
|
333
387
|
if (!opts.forceLlm) {
|
|
334
|
-
const deterministic = applyPatchToFile(original, patch)
|
|
388
|
+
const deterministic = applyPatchToFile(original, patch)
|
|
389
|
+
?? applyAdaptedIdentifiers(original, patch);
|
|
335
390
|
if (deterministic !== null) {
|
|
336
391
|
result[path] = deterministic;
|
|
337
392
|
return;
|
|
@@ -344,7 +399,20 @@ export async function smartapply(diffText, files, opts = {}) {
|
|
|
344
399
|
? { ...applyOpts, onToken: (text) => opts.onToken(text, path) }
|
|
345
400
|
: applyOpts;
|
|
346
401
|
const updated = await callLlmForApplyWithThink(path, original, patch, model, fileOpts);
|
|
347
|
-
|
|
402
|
+
const cleaned = stripBadOutput(updated, original);
|
|
403
|
+
if (!diffOutputViolatesIntent(original, cleaned, patch)) {
|
|
404
|
+
result[path] = cleaned;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// The model copied stale context or edited a line the diff did not
|
|
408
|
+
// add or remove. Replay only the +/- lines, or refuse the write.
|
|
409
|
+
const intent = applyIntentIgnoringBadContext(original, patch);
|
|
410
|
+
if (intent == null || diffOutputViolatesIntent(original, intent, patch)) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`smartapply: refusing to write ${JSON.stringify(path)}: the model changed lines the diff did not add or remove`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
result[path] = intent;
|
|
348
416
|
}),
|
|
349
417
|
);
|
|
350
418
|
|
package/src/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export * as oauth from "./oauth.js";
|
|
2
|
+
export type RelevanceDecision = import("./filter.js").RelevanceDecision;
|
|
3
|
+
export type FilterRelevantResult = import("./filter.js").FilterRelevantResult;
|
|
2
4
|
export { generateDiff, smartapply, buildEnvironment, callLlmForDiff, callLlmForApply, callLlmForApplyWithThink } from "./gptdiff.js";
|
|
5
|
+
export { filterRelevant, RelevanceCache, createMemoryCache, parseRelevanceAnswer, isBinaryContent, isIgnoredPath, countTokens, formatFilterSummary, dumpDecisions, DEFAULT_FILTER_MODEL, DEFAULT_FILTER_MAX_BYTES, DEFAULT_FILTER_MAX_TOKENS, DEFAULT_FILTER_MAX_OUTPUT_TOKENS, DEFAULT_FILTER_REASONING_EFFORT, DEFAULT_FILTER_CONCURRENCY, FILTER_PROMPT_VERSION, PROMPT_VERSION, DEFAULT_IGNORE_PATTERNS } from "./filter.js";
|
|
3
6
|
export { parseDiffPerFile, applyDiff, applyPatchToFile } from "./applydiff.js";
|
|
4
7
|
export { colorCodeDiff, swallowReasoning, stripBadOutput, extractDiffBlocks, stripThinkTags } from "./text.js";
|
|
5
8
|
export { callLlm, resolveApiKey, resolveBaseUrl } from "./llm.js";
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,31 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export { generateDiff, smartapply, buildEnvironment, callLlmForDiff, callLlmForApply, callLlmForApplyWithThink } from './gptdiff.js';
|
|
9
|
+
export {
|
|
10
|
+
filterRelevant,
|
|
11
|
+
RelevanceCache,
|
|
12
|
+
createMemoryCache,
|
|
13
|
+
parseRelevanceAnswer,
|
|
14
|
+
isBinaryContent,
|
|
15
|
+
isIgnoredPath,
|
|
16
|
+
countTokens,
|
|
17
|
+
formatFilterSummary,
|
|
18
|
+
dumpDecisions,
|
|
19
|
+
DEFAULT_FILTER_MODEL,
|
|
20
|
+
DEFAULT_FILTER_MAX_BYTES,
|
|
21
|
+
DEFAULT_FILTER_MAX_TOKENS,
|
|
22
|
+
DEFAULT_FILTER_MAX_OUTPUT_TOKENS,
|
|
23
|
+
DEFAULT_FILTER_REASONING_EFFORT,
|
|
24
|
+
DEFAULT_FILTER_CONCURRENCY,
|
|
25
|
+
FILTER_PROMPT_VERSION,
|
|
26
|
+
PROMPT_VERSION,
|
|
27
|
+
DEFAULT_IGNORE_PATTERNS,
|
|
28
|
+
} from './filter.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {import('./filter.js').RelevanceDecision} RelevanceDecision
|
|
32
|
+
* @typedef {import('./filter.js').FilterRelevantResult} FilterRelevantResult
|
|
33
|
+
*/
|
|
9
34
|
export { parseDiffPerFile, applyDiff, applyPatchToFile } from './applydiff.js';
|
|
10
35
|
export { colorCodeDiff, swallowReasoning, stripBadOutput, extractDiffBlocks, stripThinkTags } from './text.js';
|
|
11
36
|
export { callLlm, resolveApiKey, resolveBaseUrl } from './llm.js';
|
package/src/llm.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* @param {number} [opts.maxTokens]
|
|
10
10
|
* @param {number} [opts.temperature]
|
|
11
11
|
* @param {number} [opts.budgetTokens] Anthropic extended-thinking budget
|
|
12
|
+
* @param {string | null} [opts.reasoningEffort] OpenAI-compatible `reasoning_effort`
|
|
13
|
+
* (`low`, `medium`, `high`). Omitted when empty.
|
|
12
14
|
* @param {(text: string) => void} [opts.onToken] when provided, the request is
|
|
13
15
|
* made with `stream: true` and this is called once per content delta as
|
|
14
16
|
* tokens arrive. The resolved value keeps the exact non-streaming shape
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
* @param {number} [opts.retryBaseMs] base backoff in ms (default 1500, exponential, capped 30s)
|
|
20
22
|
* @returns {Promise<{choices: Array<{message: {content: string}}>, usage: object}>}
|
|
21
23
|
*/
|
|
22
|
-
export function callLlm({ apiKey, baseUrl, model, messages, maxTokens, temperature, budgetTokens, onToken, fetchImpl, maxRetries, retryBaseMs, }: {
|
|
24
|
+
export function callLlm({ apiKey, baseUrl, model, messages, maxTokens, temperature, budgetTokens, reasoningEffort, onToken, fetchImpl, maxRetries, retryBaseMs, }: {
|
|
23
25
|
apiKey?: string;
|
|
24
26
|
baseUrl?: string;
|
|
25
27
|
model: string;
|
|
@@ -27,6 +29,7 @@ export function callLlm({ apiKey, baseUrl, model, messages, maxTokens, temperatu
|
|
|
27
29
|
maxTokens?: number;
|
|
28
30
|
temperature?: number;
|
|
29
31
|
budgetTokens?: number;
|
|
32
|
+
reasoningEffort?: string | null;
|
|
30
33
|
onToken?: (text: string) => void;
|
|
31
34
|
fetchImpl?: typeof fetch;
|
|
32
35
|
maxRetries?: number;
|
package/src/llm.js
CHANGED
|
@@ -150,6 +150,8 @@ async function consumeSseStream(resp, onToken) {
|
|
|
150
150
|
* @param {number} [opts.maxTokens]
|
|
151
151
|
* @param {number} [opts.temperature]
|
|
152
152
|
* @param {number} [opts.budgetTokens] Anthropic extended-thinking budget
|
|
153
|
+
* @param {string | null} [opts.reasoningEffort] OpenAI-compatible `reasoning_effort`
|
|
154
|
+
* (`low`, `medium`, `high`). Omitted when empty.
|
|
153
155
|
* @param {(text: string) => void} [opts.onToken] when provided, the request is
|
|
154
156
|
* made with `stream: true` and this is called once per content delta as
|
|
155
157
|
* tokens arrive. The resolved value keeps the exact non-streaming shape
|
|
@@ -168,6 +170,7 @@ export async function callLlm({
|
|
|
168
170
|
maxTokens = null,
|
|
169
171
|
temperature = 1.0,
|
|
170
172
|
budgetTokens = null,
|
|
173
|
+
reasoningEffort = null,
|
|
171
174
|
onToken = null,
|
|
172
175
|
fetchImpl,
|
|
173
176
|
maxRetries = 3,
|
|
@@ -235,6 +238,7 @@ export async function callLlm({
|
|
|
235
238
|
messages,
|
|
236
239
|
temperature,
|
|
237
240
|
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
|
241
|
+
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
|
238
242
|
// Streaming: ask for SSE deltas plus a final usage chunk.
|
|
239
243
|
...(onToken ? { stream: true, stream_options: { include_usage: true } } : {}),
|
|
240
244
|
});
|