patchling 0.2.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/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +62 -0
- package/src/applydiff.d.ts +39 -0
- package/src/applydiff.js +404 -0
- package/src/env.d.ts +16 -0
- package/src/env.js +40 -0
- package/src/gptdiff.d.ts +161 -0
- package/src/gptdiff.js +352 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +13 -0
- package/src/llm.d.ts +60 -0
- package/src/llm.js +296 -0
- package/src/oauth.d.ts +111 -0
- package/src/oauth.js +266 -0
- package/src/text.d.ts +69 -0
- package/src/text.js +218 -0
package/src/env.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment access that works in both Node and the browser.
|
|
3
|
+
*
|
|
4
|
+
* In Node, reads from `process.env`. In the browser there is no `process`, so
|
|
5
|
+
* callers can register overrides (e.g. values obtained via OAuth sign-in)
|
|
6
|
+
* through `setEnv`, which are consulted first.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const overrides = Object.create(null);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Set an environment-style override (used in the browser where there is no
|
|
13
|
+
* `process.env`). Pass `undefined` to clear an override.
|
|
14
|
+
* @param {string} name
|
|
15
|
+
* @param {string | undefined} value
|
|
16
|
+
*/
|
|
17
|
+
export function setEnv(name, value) {
|
|
18
|
+
if (value === undefined) {
|
|
19
|
+
delete overrides[name];
|
|
20
|
+
} else {
|
|
21
|
+
overrides[name] = value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read an environment variable. Overrides take precedence over `process.env`.
|
|
27
|
+
* @param {string} name
|
|
28
|
+
* @param {string} [fallback]
|
|
29
|
+
* @returns {string | undefined}
|
|
30
|
+
*/
|
|
31
|
+
export function getEnv(name, fallback = undefined) {
|
|
32
|
+
if (name in overrides) return overrides[name];
|
|
33
|
+
if (typeof process !== 'undefined' && process.env && name in process.env) {
|
|
34
|
+
return process.env[name];
|
|
35
|
+
}
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_MODEL = 'xiaomi/mimo-v2.5-pro-ultraspeed';
|
|
40
|
+
export const DEFAULT_BASE_URL = 'https://nano-gpt.com/api/v1/';
|
package/src/gptdiff.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebuild the environment string from a `{ path: content }` map.
|
|
3
|
+
* @param {Record<string, string>} filesDict
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
export function buildEnvironment(filesDict: Record<string, string>): string;
|
|
7
|
+
/**
|
|
8
|
+
* Build the prompt, call the LLM, and extract the unified diff from the
|
|
9
|
+
* ```diff fenced blocks of the response.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} systemPrompt
|
|
12
|
+
* @param {string} userPrompt
|
|
13
|
+
* @param {string} filesContent
|
|
14
|
+
* @param {string} model
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
* @param {number} [opts.temperature]
|
|
17
|
+
* @param {number} [opts.maxTokens]
|
|
18
|
+
* @param {string} [opts.apiKey]
|
|
19
|
+
* @param {string} [opts.baseUrl]
|
|
20
|
+
* @param {number} [opts.budgetTokens]
|
|
21
|
+
* @param {Array} [opts.images]
|
|
22
|
+
* @param {(text: string) => void} [opts.onToken] stream callback, called per content delta
|
|
23
|
+
* @param {Function} [opts.callLlm]
|
|
24
|
+
* @returns {Promise<{ fullResponse: string, diff: string, promptTokens: number,
|
|
25
|
+
* completionTokens: number, totalTokens: number }>}
|
|
26
|
+
*/
|
|
27
|
+
export function callLlmForDiff(systemPrompt: string, userPrompt: string, filesContent: string, model: string, opts?: {
|
|
28
|
+
temperature?: number;
|
|
29
|
+
maxTokens?: number;
|
|
30
|
+
apiKey?: string;
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
budgetTokens?: number;
|
|
33
|
+
images?: any[];
|
|
34
|
+
onToken?: (text: string) => void;
|
|
35
|
+
callLlm?: Function;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
fullResponse: string;
|
|
38
|
+
diff: string;
|
|
39
|
+
promptTokens: number;
|
|
40
|
+
completionTokens: number;
|
|
41
|
+
totalTokens: number;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Generate a git diff from an environment string and a goal.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} environment the codebase as a string (see buildEnvironment)
|
|
47
|
+
* @param {string} goal natural-language instruction
|
|
48
|
+
* @param {object} [opts]
|
|
49
|
+
* @param {string} [opts.model]
|
|
50
|
+
* @param {number} [opts.temperature]
|
|
51
|
+
* @param {number} [opts.maxTokens]
|
|
52
|
+
* @param {string} [opts.apiKey]
|
|
53
|
+
* @param {string} [opts.baseUrl]
|
|
54
|
+
* @param {string} [opts.prepend] text prepended to the system prompt
|
|
55
|
+
* @param {number} [opts.budgetTokens]
|
|
56
|
+
* @param {Array} [opts.images]
|
|
57
|
+
* @param {(text: string) => void} [opts.onToken] stream callback — when
|
|
58
|
+
* provided, the LLM response is streamed and this is called once per token
|
|
59
|
+
* (content delta) as it arrives. Tokens are the raw model output (which may
|
|
60
|
+
* include prose around the ```diff block); the resolved value is still the
|
|
61
|
+
* extracted diff.
|
|
62
|
+
* @param {Function} [opts.callLlm] injectable LLM client (for testing)
|
|
63
|
+
* @param {(usage: { promptTokens: number, completionTokens: number,
|
|
64
|
+
* totalTokens: number }) => void} [opts.onUsage] optional callback invoked
|
|
65
|
+
* with token usage after the LLM call completes, so callers can surface
|
|
66
|
+
* cost/usage without changing the return type of this function.
|
|
67
|
+
* @returns {Promise<string>} the unified diff text
|
|
68
|
+
*/
|
|
69
|
+
export function generateDiff(environment: string, goal: string, opts?: {
|
|
70
|
+
model?: string;
|
|
71
|
+
temperature?: number;
|
|
72
|
+
maxTokens?: number;
|
|
73
|
+
apiKey?: string;
|
|
74
|
+
baseUrl?: string;
|
|
75
|
+
prepend?: string;
|
|
76
|
+
budgetTokens?: number;
|
|
77
|
+
images?: any[];
|
|
78
|
+
onToken?: (text: string) => void;
|
|
79
|
+
callLlm?: Function;
|
|
80
|
+
onUsage?: (usage: {
|
|
81
|
+
promptTokens: number;
|
|
82
|
+
completionTokens: number;
|
|
83
|
+
totalTokens: number;
|
|
84
|
+
}) => void;
|
|
85
|
+
}): Promise<string>;
|
|
86
|
+
/**
|
|
87
|
+
* AI-powered application of a single-file diff. Returns the full LLM response
|
|
88
|
+
* (before think/reasoning stripping).
|
|
89
|
+
*
|
|
90
|
+
* @param {string} filePath
|
|
91
|
+
* @param {string} originalContent
|
|
92
|
+
* @param {string} fileDiff
|
|
93
|
+
* @param {string} model
|
|
94
|
+
* @param {object} [opts]
|
|
95
|
+
* @param {string} [opts.apiKey]
|
|
96
|
+
* @param {string} [opts.baseUrl]
|
|
97
|
+
* @param {string} [opts.extraPrompt] appended to the user prompt
|
|
98
|
+
* @param {number} [opts.maxTokens]
|
|
99
|
+
* @param {(text: string) => void} [opts.onToken] stream callback, called once
|
|
100
|
+
* per token of the rewritten file as it arrives. Note the streamed text is
|
|
101
|
+
* the raw model output — `<think>` blocks are only stripped from the final
|
|
102
|
+
* result by {@link callLlmForApplyWithThink}.
|
|
103
|
+
* @param {Function} [opts.callLlm] injectable LLM client (for testing)
|
|
104
|
+
* @returns {Promise<string>}
|
|
105
|
+
*/
|
|
106
|
+
export function callLlmForApply(filePath: string, originalContent: string, fileDiff: string, model: string, opts?: {
|
|
107
|
+
apiKey?: string;
|
|
108
|
+
baseUrl?: string;
|
|
109
|
+
extraPrompt?: string;
|
|
110
|
+
maxTokens?: number;
|
|
111
|
+
onToken?: (text: string) => void;
|
|
112
|
+
callLlm?: Function;
|
|
113
|
+
}): Promise<string>;
|
|
114
|
+
/**
|
|
115
|
+
* Apply a diff to a single file's content, stripping `<think>` blocks and
|
|
116
|
+
* reasoning preambles from the LLM response.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} filePath
|
|
119
|
+
* @param {string} originalContent
|
|
120
|
+
* @param {string} fileDiff
|
|
121
|
+
* @param {string} model
|
|
122
|
+
* @param {object} [opts]
|
|
123
|
+
* @returns {Promise<string>} the cleaned file content
|
|
124
|
+
*/
|
|
125
|
+
export function callLlmForApplyWithThink(filePath: string, originalContent: string, fileDiff: string, model: string, opts?: object): Promise<string>;
|
|
126
|
+
/**
|
|
127
|
+
* Apply unified diffs to a map of file contents. Handles creations,
|
|
128
|
+
* modifications, and deletions. Returns a new map (the input is not mutated).
|
|
129
|
+
* Deleted files are omitted.
|
|
130
|
+
*
|
|
131
|
+
* Each file is processed in parallel. By default, every file is first applied
|
|
132
|
+
* deterministically (no LLM call) via {@link applyPatchToFile}; only files
|
|
133
|
+
* whose patch does not apply cleanly fall back to AI-powered conflict
|
|
134
|
+
* resolution. Set `opts.forceLlm` to skip the deterministic fast path and send
|
|
135
|
+
* every file to the LLM.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} diffText
|
|
138
|
+
* @param {Record<string, string>} files
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {string} [opts.model]
|
|
141
|
+
* @param {string} [opts.apiKey]
|
|
142
|
+
* @param {string} [opts.baseUrl]
|
|
143
|
+
* @param {number} [opts.maxTokens]
|
|
144
|
+
* @param {boolean} [opts.forceLlm] skip the deterministic fast path
|
|
145
|
+
* @param {(text: string, path: string) => void} [opts.onToken] stream
|
|
146
|
+
* callback for LLM-resolved files. Files are processed concurrently, so
|
|
147
|
+
* token calls for different files interleave; the second argument is the
|
|
148
|
+
* file path the token belongs to, letting callers demultiplex. Files taken
|
|
149
|
+
* by the deterministic fast path emit no tokens (no LLM call is made).
|
|
150
|
+
* @param {Function} [opts.callLlmForApply] injectable single-file applier
|
|
151
|
+
* @returns {Promise<Record<string, string>>}
|
|
152
|
+
*/
|
|
153
|
+
export function smartapply(diffText: string, files: Record<string, string>, opts?: {
|
|
154
|
+
model?: string;
|
|
155
|
+
apiKey?: string;
|
|
156
|
+
baseUrl?: string;
|
|
157
|
+
maxTokens?: number;
|
|
158
|
+
forceLlm?: boolean;
|
|
159
|
+
onToken?: (text: string, path: string) => void;
|
|
160
|
+
callLlmForApply?: Function;
|
|
161
|
+
}): Promise<Record<string, string>>;
|
package/src/gptdiff.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gptdiff-js core: generateDiff + smartapply.
|
|
3
|
+
*
|
|
4
|
+
* A browser-first port of the gptdiff Python package, scoped to the two main
|
|
5
|
+
* APIs. LLM access is injectable (`opts.callLlm` / `opts.callLlmForApply`) so
|
|
6
|
+
* the logic is fully unit-testable without network access.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseDiffPerFile, applyPatchToFile } from './applydiff.js';
|
|
10
|
+
import {
|
|
11
|
+
swallowReasoning,
|
|
12
|
+
stripBadOutput,
|
|
13
|
+
stripThinkTags,
|
|
14
|
+
extractDiffBlocks,
|
|
15
|
+
} from './text.js';
|
|
16
|
+
import {
|
|
17
|
+
callLlm as defaultCallLlm,
|
|
18
|
+
resolveApiKey,
|
|
19
|
+
resolveBaseUrl,
|
|
20
|
+
} from './llm.js';
|
|
21
|
+
import { getEnv, DEFAULT_MODEL } from './env.js';
|
|
22
|
+
|
|
23
|
+
const DIFF_TOOL_PROMPT = `Save the calculated diff as used in 'git apply'. Should include the file and line number. For example:
|
|
24
|
+
\`\`\`diff
|
|
25
|
+
a/file.py b/file.py
|
|
26
|
+
--- a/file.py
|
|
27
|
+
+++ b/file.py
|
|
28
|
+
@@ -1,2 +1,2 @@
|
|
29
|
+
-def old():
|
|
30
|
+
+def new():
|
|
31
|
+
\`\`\`
|
|
32
|
+
|
|
33
|
+
You must include the '--- file' and/or '+++ file' part of the diff. File modifications should include both.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const APPLY_SYSTEM_PROMPT = `Please apply the diff to this file. Return the result in a block. Write the entire file.
|
|
37
|
+
|
|
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.`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Rebuild the environment string from a `{ path: content }` map.
|
|
45
|
+
* @param {Record<string, string>} filesDict
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function buildEnvironment(filesDict) {
|
|
49
|
+
const env = [];
|
|
50
|
+
for (const [path, content] of Object.entries(filesDict)) {
|
|
51
|
+
env.push(`File: ${path}`);
|
|
52
|
+
env.push('Content:');
|
|
53
|
+
env.push(content);
|
|
54
|
+
}
|
|
55
|
+
return env.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveModel(model) {
|
|
59
|
+
return model || getEnv('GPTDIFF_MODEL', DEFAULT_MODEL) || DEFAULT_MODEL;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function usageFrom(response) {
|
|
63
|
+
const usage = response && response.usage;
|
|
64
|
+
if (!usage) return { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
65
|
+
const promptTokens = usage.prompt_tokens ?? usage.input_tokens ?? 0;
|
|
66
|
+
const completionTokens = usage.completion_tokens ?? usage.output_tokens ?? 0;
|
|
67
|
+
const totalTokens = usage.total_tokens ?? promptTokens + completionTokens;
|
|
68
|
+
return { promptTokens, completionTokens, totalTokens };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the prompt, call the LLM, and extract the unified diff from the
|
|
73
|
+
* ```diff fenced blocks of the response.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} systemPrompt
|
|
76
|
+
* @param {string} userPrompt
|
|
77
|
+
* @param {string} filesContent
|
|
78
|
+
* @param {string} model
|
|
79
|
+
* @param {object} [opts]
|
|
80
|
+
* @param {number} [opts.temperature]
|
|
81
|
+
* @param {number} [opts.maxTokens]
|
|
82
|
+
* @param {string} [opts.apiKey]
|
|
83
|
+
* @param {string} [opts.baseUrl]
|
|
84
|
+
* @param {number} [opts.budgetTokens]
|
|
85
|
+
* @param {Array} [opts.images]
|
|
86
|
+
* @param {(text: string) => void} [opts.onToken] stream callback, called per content delta
|
|
87
|
+
* @param {Function} [opts.callLlm]
|
|
88
|
+
* @returns {Promise<{ fullResponse: string, diff: string, promptTokens: number,
|
|
89
|
+
* completionTokens: number, totalTokens: number }>}
|
|
90
|
+
*/
|
|
91
|
+
export async function callLlmForDiff(systemPrompt, userPrompt, filesContent, model, opts = {}) {
|
|
92
|
+
const {
|
|
93
|
+
temperature = 1.0,
|
|
94
|
+
maxTokens = null,
|
|
95
|
+
apiKey,
|
|
96
|
+
baseUrl,
|
|
97
|
+
budgetTokens = null,
|
|
98
|
+
images = null,
|
|
99
|
+
onToken = null,
|
|
100
|
+
callLlm = defaultCallLlm,
|
|
101
|
+
} = opts;
|
|
102
|
+
|
|
103
|
+
const fullSystemPrompt = systemPrompt + '\n' + DIFF_TOOL_PROMPT;
|
|
104
|
+
|
|
105
|
+
let effectiveUserPrompt = userPrompt;
|
|
106
|
+
if (model && model.includes('gemini')) {
|
|
107
|
+
effectiveUserPrompt = fullSystemPrompt + '\n' + userPrompt;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const userText = effectiveUserPrompt + '\n' + filesContent;
|
|
111
|
+
/** @type {string | Array<{type: string, text?: string, image_url?: {url: string}}>} */
|
|
112
|
+
let userContent = userText;
|
|
113
|
+
if (images && images.length) {
|
|
114
|
+
/** @type {Array<{type: string, text?: string, image_url?: {url: string}}>} */
|
|
115
|
+
const blocks = [{ type: 'text', text: userText }];
|
|
116
|
+
for (const image of images) {
|
|
117
|
+
const dataUrl = `data:${image.media_type};base64,${image.data}`;
|
|
118
|
+
blocks.push({ type: 'image_url', image_url: { url: dataUrl } });
|
|
119
|
+
}
|
|
120
|
+
userContent = blocks;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const messages = [
|
|
124
|
+
{ role: 'system', content: fullSystemPrompt },
|
|
125
|
+
{ role: 'user', content: userContent },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const response = await callLlm({
|
|
129
|
+
apiKey: resolveApiKey(apiKey),
|
|
130
|
+
baseUrl: resolveBaseUrl(baseUrl),
|
|
131
|
+
model,
|
|
132
|
+
messages,
|
|
133
|
+
maxTokens,
|
|
134
|
+
temperature,
|
|
135
|
+
budgetTokens,
|
|
136
|
+
onToken,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const { promptTokens, completionTokens, totalTokens } = usageFrom(response);
|
|
140
|
+
|
|
141
|
+
let fullResponse = (response.choices[0].message.content || '').trim();
|
|
142
|
+
const [stripped] = swallowReasoning(fullResponse);
|
|
143
|
+
fullResponse = stripped;
|
|
144
|
+
|
|
145
|
+
const diff = extractDiffBlocks(fullResponse).join('\n');
|
|
146
|
+
|
|
147
|
+
return { fullResponse, diff, promptTokens, completionTokens, totalTokens };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Generate a git diff from an environment string and a goal.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} environment the codebase as a string (see buildEnvironment)
|
|
154
|
+
* @param {string} goal natural-language instruction
|
|
155
|
+
* @param {object} [opts]
|
|
156
|
+
* @param {string} [opts.model]
|
|
157
|
+
* @param {number} [opts.temperature]
|
|
158
|
+
* @param {number} [opts.maxTokens]
|
|
159
|
+
* @param {string} [opts.apiKey]
|
|
160
|
+
* @param {string} [opts.baseUrl]
|
|
161
|
+
* @param {string} [opts.prepend] text prepended to the system prompt
|
|
162
|
+
* @param {number} [opts.budgetTokens]
|
|
163
|
+
* @param {Array} [opts.images]
|
|
164
|
+
* @param {(text: string) => void} [opts.onToken] stream callback — when
|
|
165
|
+
* provided, the LLM response is streamed and this is called once per token
|
|
166
|
+
* (content delta) as it arrives. Tokens are the raw model output (which may
|
|
167
|
+
* include prose around the ```diff block); the resolved value is still the
|
|
168
|
+
* extracted diff.
|
|
169
|
+
* @param {Function} [opts.callLlm] injectable LLM client (for testing)
|
|
170
|
+
* @param {(usage: { promptTokens: number, completionTokens: number,
|
|
171
|
+
* totalTokens: number }) => void} [opts.onUsage] optional callback invoked
|
|
172
|
+
* with token usage after the LLM call completes, so callers can surface
|
|
173
|
+
* cost/usage without changing the return type of this function.
|
|
174
|
+
* @returns {Promise<string>} the unified diff text
|
|
175
|
+
*/
|
|
176
|
+
export async function generateDiff(environment, goal, opts = {}) {
|
|
177
|
+
const model = resolveModel(opts.model);
|
|
178
|
+
const prepend = opts.prepend ? opts.prepend + '\n' : '';
|
|
179
|
+
const systemPrompt = prepend + 'Output a full unified git diff into a "```diff" block.';
|
|
180
|
+
|
|
181
|
+
const { diff, promptTokens, completionTokens, totalTokens } = await callLlmForDiff(
|
|
182
|
+
systemPrompt,
|
|
183
|
+
goal,
|
|
184
|
+
environment,
|
|
185
|
+
model,
|
|
186
|
+
{
|
|
187
|
+
temperature: opts.temperature ?? 1.0,
|
|
188
|
+
// No default cap: let the model emit its full diff (avoids silent truncation
|
|
189
|
+
// that produces an unparseable diff). Callers may still pass an explicit maxTokens.
|
|
190
|
+
maxTokens: opts.maxTokens ?? null,
|
|
191
|
+
apiKey: opts.apiKey,
|
|
192
|
+
baseUrl: opts.baseUrl,
|
|
193
|
+
budgetTokens: opts.budgetTokens ?? null,
|
|
194
|
+
images: opts.images ?? null,
|
|
195
|
+
onToken: opts.onToken ?? null,
|
|
196
|
+
callLlm: opts.callLlm,
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (typeof opts.onUsage === 'function') {
|
|
201
|
+
opts.onUsage({ promptTokens, completionTokens, totalTokens });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return diff;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* AI-powered application of a single-file diff. Returns the full LLM response
|
|
209
|
+
* (before think/reasoning stripping).
|
|
210
|
+
*
|
|
211
|
+
* @param {string} filePath
|
|
212
|
+
* @param {string} originalContent
|
|
213
|
+
* @param {string} fileDiff
|
|
214
|
+
* @param {string} model
|
|
215
|
+
* @param {object} [opts]
|
|
216
|
+
* @param {string} [opts.apiKey]
|
|
217
|
+
* @param {string} [opts.baseUrl]
|
|
218
|
+
* @param {string} [opts.extraPrompt] appended to the user prompt
|
|
219
|
+
* @param {number} [opts.maxTokens]
|
|
220
|
+
* @param {(text: string) => void} [opts.onToken] stream callback, called once
|
|
221
|
+
* per token of the rewritten file as it arrives. Note the streamed text is
|
|
222
|
+
* the raw model output — `<think>` blocks are only stripped from the final
|
|
223
|
+
* result by {@link callLlmForApplyWithThink}.
|
|
224
|
+
* @param {Function} [opts.callLlm] injectable LLM client (for testing)
|
|
225
|
+
* @returns {Promise<string>}
|
|
226
|
+
*/
|
|
227
|
+
export async function callLlmForApply(filePath, originalContent, fileDiff, model, opts = {}) {
|
|
228
|
+
const {
|
|
229
|
+
apiKey,
|
|
230
|
+
baseUrl,
|
|
231
|
+
extraPrompt = null,
|
|
232
|
+
maxTokens = null,
|
|
233
|
+
onToken = null,
|
|
234
|
+
callLlm = defaultCallLlm,
|
|
235
|
+
} = opts;
|
|
236
|
+
|
|
237
|
+
let userPrompt = `File: ${filePath}
|
|
238
|
+
File contents:
|
|
239
|
+
\`\`\`
|
|
240
|
+
${originalContent}
|
|
241
|
+
\`\`\`
|
|
242
|
+
|
|
243
|
+
Diff to apply:
|
|
244
|
+
\`\`\`diff
|
|
245
|
+
${fileDiff}
|
|
246
|
+
\`\`\``;
|
|
247
|
+
if (extraPrompt) userPrompt += `\n\n${extraPrompt}`;
|
|
248
|
+
if (model && model.includes('gemini')) {
|
|
249
|
+
userPrompt = APPLY_SYSTEM_PROMPT + '\n' + userPrompt;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const messages = [
|
|
253
|
+
{ role: 'system', content: APPLY_SYSTEM_PROMPT },
|
|
254
|
+
{ role: 'user', content: userPrompt },
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
const response = await callLlm({
|
|
258
|
+
apiKey: resolveApiKey(apiKey),
|
|
259
|
+
baseUrl: resolveBaseUrl(baseUrl),
|
|
260
|
+
model,
|
|
261
|
+
messages,
|
|
262
|
+
maxTokens,
|
|
263
|
+
temperature: 0.0,
|
|
264
|
+
onToken,
|
|
265
|
+
});
|
|
266
|
+
return response.choices[0].message.content;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Apply a diff to a single file's content, stripping `<think>` blocks and
|
|
271
|
+
* reasoning preambles from the LLM response.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} filePath
|
|
274
|
+
* @param {string} originalContent
|
|
275
|
+
* @param {string} fileDiff
|
|
276
|
+
* @param {string} model
|
|
277
|
+
* @param {object} [opts]
|
|
278
|
+
* @returns {Promise<string>} the cleaned file content
|
|
279
|
+
*/
|
|
280
|
+
export async function callLlmForApplyWithThink(filePath, originalContent, fileDiff, model, opts = {}) {
|
|
281
|
+
const applyFn = opts.callLlmForApply || callLlmForApply;
|
|
282
|
+
const fullResponse = await applyFn(filePath, originalContent, fileDiff, model, opts);
|
|
283
|
+
return stripThinkTags(fullResponse);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Apply unified diffs to a map of file contents. Handles creations,
|
|
288
|
+
* modifications, and deletions. Returns a new map (the input is not mutated).
|
|
289
|
+
* Deleted files are omitted.
|
|
290
|
+
*
|
|
291
|
+
* Each file is processed in parallel. By default, every file is first applied
|
|
292
|
+
* deterministically (no LLM call) via {@link applyPatchToFile}; only files
|
|
293
|
+
* whose patch does not apply cleanly fall back to AI-powered conflict
|
|
294
|
+
* resolution. Set `opts.forceLlm` to skip the deterministic fast path and send
|
|
295
|
+
* every file to the LLM.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} diffText
|
|
298
|
+
* @param {Record<string, string>} files
|
|
299
|
+
* @param {object} [opts]
|
|
300
|
+
* @param {string} [opts.model]
|
|
301
|
+
* @param {string} [opts.apiKey]
|
|
302
|
+
* @param {string} [opts.baseUrl]
|
|
303
|
+
* @param {number} [opts.maxTokens]
|
|
304
|
+
* @param {boolean} [opts.forceLlm] skip the deterministic fast path
|
|
305
|
+
* @param {(text: string, path: string) => void} [opts.onToken] stream
|
|
306
|
+
* callback for LLM-resolved files. Files are processed concurrently, so
|
|
307
|
+
* token calls for different files interleave; the second argument is the
|
|
308
|
+
* file path the token belongs to, letting callers demultiplex. Files taken
|
|
309
|
+
* by the deterministic fast path emit no tokens (no LLM call is made).
|
|
310
|
+
* @param {Function} [opts.callLlmForApply] injectable single-file applier
|
|
311
|
+
* @returns {Promise<Record<string, string>>}
|
|
312
|
+
*/
|
|
313
|
+
export async function smartapply(diffText, files, opts = {}) {
|
|
314
|
+
const model = resolveModel(opts.model);
|
|
315
|
+
const parsedDiffs = parseDiffPerFile(diffText);
|
|
316
|
+
const result = { ...files };
|
|
317
|
+
|
|
318
|
+
const applyOpts = {
|
|
319
|
+
apiKey: opts.apiKey,
|
|
320
|
+
baseUrl: opts.baseUrl,
|
|
321
|
+
maxTokens: opts.maxTokens,
|
|
322
|
+
callLlmForApply: opts.callLlmForApply,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
await Promise.all(
|
|
326
|
+
parsedDiffs.map(async ([path, patch]) => {
|
|
327
|
+
const original = Object.prototype.hasOwnProperty.call(result, path) ? result[path] : '';
|
|
328
|
+
if (patch.includes('+++ /dev/null')) {
|
|
329
|
+
delete result[path];
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Fast path: try to apply the patch deterministically, no LLM call.
|
|
333
|
+
if (!opts.forceLlm) {
|
|
334
|
+
const deterministic = applyPatchToFile(original, patch);
|
|
335
|
+
if (deterministic !== null) {
|
|
336
|
+
result[path] = deterministic;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Fallback: the patch did not apply cleanly, let the LLM resolve it.
|
|
341
|
+
// Bind the file path into the stream callback so concurrent per-file
|
|
342
|
+
// streams can be told apart by the caller: onToken(text, path).
|
|
343
|
+
const fileOpts = opts.onToken
|
|
344
|
+
? { ...applyOpts, onToken: (text) => opts.onToken(text, path) }
|
|
345
|
+
: applyOpts;
|
|
346
|
+
const updated = await callLlmForApplyWithThink(path, original, patch, model, fileOpts);
|
|
347
|
+
result[path] = stripBadOutput(updated, original);
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
return result;
|
|
352
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * as oauth from "./oauth.js";
|
|
2
|
+
export { generateDiff, smartapply, buildEnvironment, callLlmForDiff, callLlmForApply, callLlmForApplyWithThink } from "./gptdiff.js";
|
|
3
|
+
export { parseDiffPerFile, applyDiff, applyPatchToFile } from "./applydiff.js";
|
|
4
|
+
export { colorCodeDiff, swallowReasoning, stripBadOutput, extractDiffBlocks, stripThinkTags } from "./text.js";
|
|
5
|
+
export { callLlm, resolveApiKey, resolveBaseUrl } from "./llm.js";
|
|
6
|
+
export { getEnv, setEnv, DEFAULT_MODEL, DEFAULT_BASE_URL } from "./env.js";
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gptdiff-js — generate and apply git diffs using LLMs, in the browser.
|
|
3
|
+
*
|
|
4
|
+
* Public API mirrors the gptdiff Python package, scoped to generateDiff +
|
|
5
|
+
* smartapply and their building blocks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { generateDiff, smartapply, buildEnvironment, callLlmForDiff, callLlmForApply, callLlmForApplyWithThink } from './gptdiff.js';
|
|
9
|
+
export { parseDiffPerFile, applyDiff, applyPatchToFile } from './applydiff.js';
|
|
10
|
+
export { colorCodeDiff, swallowReasoning, stripBadOutput, extractDiffBlocks, stripThinkTags } from './text.js';
|
|
11
|
+
export { callLlm, resolveApiKey, resolveBaseUrl } from './llm.js';
|
|
12
|
+
export { getEnv, setEnv, DEFAULT_MODEL, DEFAULT_BASE_URL } from './env.js';
|
|
13
|
+
export * as oauth from './oauth.js';
|
package/src/llm.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call a chat-completions endpoint.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} opts
|
|
5
|
+
* @param {string} [opts.apiKey]
|
|
6
|
+
* @param {string} [opts.baseUrl]
|
|
7
|
+
* @param {string} opts.model
|
|
8
|
+
* @param {Array<object>} opts.messages
|
|
9
|
+
* @param {number} [opts.maxTokens]
|
|
10
|
+
* @param {number} [opts.temperature]
|
|
11
|
+
* @param {number} [opts.budgetTokens] Anthropic extended-thinking budget
|
|
12
|
+
* @param {(text: string) => void} [opts.onToken] when provided, the request is
|
|
13
|
+
* made with `stream: true` and this is called once per content delta as
|
|
14
|
+
* tokens arrive. The resolved value keeps the exact non-streaming shape
|
|
15
|
+
* (`{ choices: [{ message: { content } }], usage }`), so callers work
|
|
16
|
+
* unchanged. Ignored on the Anthropic branch (which stays non-streaming).
|
|
17
|
+
* @param {typeof fetch} [opts.fetchImpl] override for testing
|
|
18
|
+
* @param {number} [opts.maxRetries] retries on transient 429/5xx (default 3)
|
|
19
|
+
* @param {number} [opts.retryBaseMs] base backoff in ms (default 1500, exponential, capped 30s)
|
|
20
|
+
* @returns {Promise<{choices: Array<{message: {content: string}}>, usage: object}>}
|
|
21
|
+
*/
|
|
22
|
+
export function callLlm({ apiKey, baseUrl, model, messages, maxTokens, temperature, budgetTokens, onToken, fetchImpl, maxRetries, retryBaseMs, }: {
|
|
23
|
+
apiKey?: string;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
model: string;
|
|
26
|
+
messages: Array<object>;
|
|
27
|
+
maxTokens?: number;
|
|
28
|
+
temperature?: number;
|
|
29
|
+
budgetTokens?: number;
|
|
30
|
+
onToken?: (text: string) => void;
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
maxRetries?: number;
|
|
33
|
+
retryBaseMs?: number;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
choices: Array<{
|
|
36
|
+
message: {
|
|
37
|
+
content: string;
|
|
38
|
+
};
|
|
39
|
+
}>;
|
|
40
|
+
usage: object;
|
|
41
|
+
}>;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the effective API key, preferring an explicit override then the
|
|
44
|
+
* GPTDIFF_LLM_API_KEY environment variable.
|
|
45
|
+
* @param {string} [apiKey]
|
|
46
|
+
* @returns {string | undefined}
|
|
47
|
+
*/
|
|
48
|
+
export function resolveApiKey(apiKey?: string): string | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the effective base URL, preferring an explicit override then the
|
|
51
|
+
* GPTDIFF_LLM_BASE_URL environment variable, then the NanoGPT default.
|
|
52
|
+
* @param {string} [baseUrl]
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function resolveBaseUrl(baseUrl?: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} baseUrl
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function domainForUrl(baseUrl: string): string;
|