patchling 0.2.0 → 0.3.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/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>): 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 { parseDiffPerFile, applyPatchToFile } from './applydiff.js';
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 result in a block. Write the entire file.
42
+ const APPLY_SYSTEM_PROMPT = `Please apply the diff to this file. Return the full file contents.
37
43
 
38
- 1. Carefully apply all changes from the diff
39
- 2. Preserve surrounding context that isn't changed
40
- 3. Only return the final file content, do not add any additional markup and do not add a code block
41
- 4. You must return the entire file. It overwrites the existing file.`;
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 Object.entries(filesDict)) {
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
- result[path] = stripBadOutput(updated, original);
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
  });