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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 255BITS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # patchling
2
+
3
+ <p align="center">
4
+ <img src="infographic.svg" alt="patchling: Smart Diffs, Applied Intelligently — generateDiff creates a unified diff from your files and goal, smartapply resolves conflicts per-file with LLM, and you iterate until done" width="100%">
5
+ </p>
6
+
7
+ > _Formerly **gptdiff-js**._
8
+
9
+ **Natural-language code transformation, browser-first.** Hand patchling an in-memory `{ path: content }` file map plus a plain-English goal; **`generateDiff`** returns a unified git diff, and **`smartapply`** melds that diff into your source — even when `git apply` would reject it (drifted lines, fuzzy hunks, renames, new files, deletions). It's a bounded primitive you embed in your own app, not an open-ended coding agent. Wired to [NanoGPT](https://nano-gpt.com) for completions (including "Sign in with NanoGPT" OAuth PKCE).
10
+
11
+ Everything runs in the browser (and in Node 18+): no filesystem, no build step, zero runtime dependencies. The diff engine is a faithful port of the Python [gptdiff](https://github.com/255BITS/gptdiff); it operates on an in-memory file map instead of a directory on disk.
12
+
13
+ See it in action: **[live browser demos →](https://255bits.github.io/gptdiff-js-examples/)**.
14
+
15
+ > **The family** —
16
+ > [**gptdiff**](https://github.com/255BITS/gptdiff) (Python library + CLI) ·
17
+ > **patchling** (this package — the browser/Node runtime) ·
18
+ > [**nanoodle.com**](https://nanoodle.com) (visual AI workflow editor built on it)
19
+
20
+ ```js
21
+ import { generateDiff, smartapply, buildEnvironment } from 'patchling';
22
+
23
+ const files = { 'greet.py': 'def greet():\n print("hello")\n' };
24
+
25
+ // 1. Ask the model for a unified diff
26
+ const diff = await generateDiff(buildEnvironment(files), 'Say goodbye instead of hello');
27
+
28
+ // 2. Apply it with AI-powered conflict resolution
29
+ const updated = await smartapply(diff, files);
30
+ console.log(updated['greet.py']);
31
+ ```
32
+
33
+ ## Install / run
34
+
35
+ ```bash
36
+ npm install patchling
37
+ ```
38
+
39
+ ```js
40
+ import { generateDiff, smartapply } from 'patchling';
41
+ ```
42
+
43
+ **Browser, no build step** — it's zero-dependency ESM, so a CDN import works directly (once the package is published to npm):
44
+
45
+ ```js
46
+ import { generateDiff, smartapply } from 'https://esm.sh/patchling';
47
+ ```
48
+
49
+ **Run from source** — point an import at `src/index.js`, or open `index.html` from a static server:
50
+
51
+ ```bash
52
+ npx serve . # then visit the printed URL and try the demo
53
+ npm test # run the unit suite (Node's built-in test runner)
54
+ npm run test:live # hit NanoGPT for real (requires env vars, see below)
55
+ ```
56
+
57
+ ## Configuration
58
+
59
+ Configuration comes from environment variables (Node) or `setEnv(...)` overrides (browser). OAuth sign-in sets the API key override for you.
60
+
61
+ | Variable | Purpose | Default |
62
+ | --- | --- | --- |
63
+ | `GPTDIFF_LLM_API_KEY` | NanoGPT API key (`sk-nano-…`) | — (required for live calls) |
64
+ | `GPTDIFF_LLM_BASE_URL` | OpenAI-compatible base URL | `https://nano-gpt.com/api/v1/` |
65
+ | `GPTDIFF_MODEL` | Model id | `xiaomi/mimo-v2.5-pro-ultraspeed` |
66
+
67
+ ```js
68
+ import { setEnv } from 'patchling';
69
+ setEnv('GPTDIFF_LLM_API_KEY', 'sk-nano-…'); // browser, no process.env
70
+ ```
71
+
72
+ ## Sign in with NanoGPT (OAuth 2.0 + PKCE)
73
+
74
+ `src/oauth.js` implements the [NanoGPT OAuth PKCE flow](https://nano-gpt.com/blog/sign-in-with-nanogpt-oauth-pkce) using Web Crypto. In a browser:
75
+
76
+ ```js
77
+ import { registerClient, beginSignIn, completeSignIn } from 'patchling/oauth';
78
+
79
+ // On page load — finishes the redirect and stores the access token as
80
+ // the GPTDIFF_LLM_API_KEY override automatically:
81
+ await completeSignIn();
82
+
83
+ // To start sign-in (clientId from a one-time dynamic registration):
84
+ const { client_id } = await registerClient({
85
+ clientName: 'My App',
86
+ redirectUri: location.origin + location.pathname,
87
+ });
88
+ await beginSignIn({ clientId: client_id }); // redirects to NanoGPT
89
+ ```
90
+
91
+ Low-level helpers are also exported: `generatePkce`, `buildAuthorizeUrl`, `exchangeCodeForToken`, `generateCodeChallenge`.
92
+
93
+ ## API
94
+
95
+ ### `generateDiff(environment, goal, opts?) → Promise<string>`
96
+ Builds the prompt, calls the LLM, and returns the unified diff extracted from the ` ```diff ` block(s) of the response.
97
+
98
+ - `opts.model`, `opts.temperature`, `opts.maxTokens`, `opts.apiKey`, `opts.baseUrl`, `opts.prepend`, `opts.images`
99
+ - `opts.onToken` — `(text) => void` streaming callback. When provided, the completion is streamed (SSE) and called once per token as it arrives; the returned promise still resolves to the extracted diff. Tokens are the raw model output, which may include prose around the ` ```diff ` block.
100
+ - `opts.callLlm` — inject a custom/mock completion client (used heavily in tests).
101
+ - `opts.onUsage` — optional callback invoked with `{ promptTokens, completionTokens, totalTokens }` after the LLM call completes, so callers can surface cost/usage without changing the `Promise<string>` return type.
102
+
103
+ ### `smartapply(diffText, files, opts?) → Promise<{ path: content }>`
104
+ Applies a diff to an in-memory file map with per-file, LLM-assisted conflict resolution (runs files concurrently). Handles creation, modification, and deletion; `<think>…</think>` and reasoning preambles are stripped automatically. Returns a **new** map; deleted files are omitted.
105
+
106
+ - `opts.model`, `opts.apiKey`, `opts.baseUrl`, `opts.maxTokens`
107
+ - `opts.onToken` — `(text, path) => void` streaming callback for LLM-resolved files. Files stream concurrently, so calls for different files interleave; use the `path` argument to demultiplex. Files applied by the deterministic fast path emit no tokens.
108
+ - `opts.callLlmForApply` — inject a custom/mock single-file applier.
109
+
110
+ ### `applyDiff(files, diffText) → { changed, files }`
111
+ Deterministic, no-LLM patch application (the strict "basic" applier). `changed` is `true` iff something was created, modified, or deleted; `files` is the updated map (equal to the input on failure — no partial writes).
112
+
113
+ ### Other exports
114
+ `parseDiffPerFile`, `buildEnvironment`, `colorCodeDiff`, `swallowReasoning`, `stripBadOutput`, `extractDiffBlocks`, `callLlm`, `resolveApiKey`, `resolveBaseUrl`, `getEnv`, `setEnv`, and the `oauth` namespace.
115
+
116
+ ## Differences from the Python package
117
+
118
+ - **No filesystem.** `applyDiff`/`smartapply` take and return a `{ path: content }` map rather than reading/writing a project directory.
119
+ - **Scope.** Only `generateDiff` + `smartapply` (and their dependencies) are ported — not the `gptdiff`/`gptpatch`/`plangptdiff` CLIs.
120
+ - **Dependency injection** replaces Python's `monkeypatch`: pass `callLlm` / `callLlmForApply` to test without a network.
121
+ - **`fetch` + Web Crypto** replace `openai`/`requests`/`tiktoken`.
122
+
123
+ ## License
124
+
125
+ MIT
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "patchling",
3
+ "version": "0.2.0",
4
+ "description": "Natural-language code transformation as a browser-first JS primitive: generateDiff turns a plain-English goal into a git diff, and smartapply melds it into your source even when git apply would reject it. Wired to NanoGPT. Formerly gptdiff-js.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./oauth": {
14
+ "types": "./src/oauth.d.ts",
15
+ "default": "./src/oauth.js"
16
+ },
17
+ "./llm": {
18
+ "types": "./src/llm.d.ts",
19
+ "default": "./src/llm.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "sideEffects": false,
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/255BITS/gptdiff-js.git"
29
+ },
30
+ "homepage": "https://github.com/255BITS/gptdiff-js#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/255BITS/gptdiff-js/issues"
33
+ },
34
+ "scripts": {
35
+ "prepublishOnly": "npm test",
36
+ "test": "node --test",
37
+ "test:live": "GPTDIFF_LIVE=1 node --test test/live.test.js",
38
+ "benchmark": "node benchmark/run.js",
39
+ "types": "rm -f src/*.d.ts && tsc -p tsconfig.json"
40
+ },
41
+ "keywords": [
42
+ "patchling",
43
+ "smartapply",
44
+ "diff",
45
+ "patch",
46
+ "llm",
47
+ "code-transformation",
48
+ "nanogpt",
49
+ "git",
50
+ "browser",
51
+ "gptdiff"
52
+ ],
53
+ "author": "255labs",
54
+ "license": "MIT",
55
+ "engines": {
56
+ "node": ">=18"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^20.19.43",
60
+ "typescript": "^5.9.3"
61
+ }
62
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Parse unified diff text into individual per-file patches.
3
+ *
4
+ * Handles file creations (`+++ /dev/null`), deletions (`--- /dev/null` or
5
+ * "deleted file mode"), standard modifications, headerless LLM diffs, and the
6
+ * `*** Begin Patch` / `*** Update File:` delimiter style.
7
+ *
8
+ * @param {string} diffText
9
+ * @returns {Array<[string, string]>} list of [filePath, patch] tuples
10
+ */
11
+ export function parseDiffPerFile(diffText: string): Array<[string, string]>;
12
+ /**
13
+ * Apply a single-file unified diff patch to `originalContent`.
14
+ *
15
+ * Each hunk is located by matching its context+deletion lines against the
16
+ * original rather than trusting the `@@` line numbers, which LLM-generated
17
+ * diffs frequently get slightly wrong. The `@@` start line is used only as a
18
+ * hint to disambiguate between multiple matching positions. The full block of
19
+ * context and deletion lines must match exactly, so a hunk that does not apply
20
+ * cleanly returns `null` instead of silently corrupting the file.
21
+ *
22
+ * @param {string} originalContent
23
+ * @param {string} patch
24
+ * @returns {string | null} the patched content, or null if the patch fails
25
+ */
26
+ export function applyPatchToFile(originalContent: string, patch: string): string | null;
27
+ /**
28
+ * Apply a unified diff to an in-memory file map.
29
+ *
30
+ * @param {Record<string, string>} files map of path -> content
31
+ * @param {string} diffText unified diff string
32
+ * @returns {{ changed: boolean, files: Record<string, string> }}
33
+ * `changed` is true iff at least one file was created, modified, or deleted.
34
+ * `files` is a new map; on failure it equals the input (no partial changes).
35
+ */
36
+ export function applyDiff(files: Record<string, string>, diffText: string): {
37
+ changed: boolean;
38
+ files: Record<string, string>;
39
+ };
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Diff parsing and in-memory diff application, ported from gptdiff's
3
+ * applydiff.py. The browser has no filesystem, so `applyDiff` operates on a
4
+ * plain `{ path: content }` map instead of a project directory on disk.
5
+ */
6
+
7
+ import { splitLines } from './text.js';
8
+
9
+ /**
10
+ * Parse unified diff text into individual per-file patches.
11
+ *
12
+ * Handles file creations (`+++ /dev/null`), deletions (`--- /dev/null` or
13
+ * "deleted file mode"), standard modifications, headerless LLM diffs, and the
14
+ * `*** Begin Patch` / `*** Update File:` delimiter style.
15
+ *
16
+ * @param {string} diffText
17
+ * @returns {Array<[string, string]>} list of [filePath, patch] tuples
18
+ */
19
+ export function parseDiffPerFile(diffText) {
20
+ /**
21
+ * @param {Array<[string, string]>} diffs
22
+ * @returns {Array<[string, string]>}
23
+ */
24
+ const dedupDiffs = (diffs) => {
25
+ const groups = new Map();
26
+ for (const [key, value] of diffs) {
27
+ if (!groups.has(key)) groups.set(key, []);
28
+ groups.get(key).push(value);
29
+ }
30
+ return Array.from(groups.entries()).map(([key, values]) => /** @type {[string, string]} */ ([key, values.join('\n')]));
31
+ };
32
+
33
+ // Special case: handle LLM-style patch delimiters.
34
+ if (diffText.includes('*** Begin Patch')) {
35
+ const lines = splitLines(diffText);
36
+ /** @type {Array<[string, string]>} */
37
+ const diffs = [];
38
+ let currentLines = [];
39
+ let currentFile = null;
40
+ let inPatch = false;
41
+ for (const line of lines) {
42
+ const stripped = line.trim();
43
+ if (stripped === '*** Begin Patch') {
44
+ inPatch = true;
45
+ currentLines = [];
46
+ currentFile = null;
47
+ } else if (stripped === '*** End Patch') {
48
+ if (currentFile !== null) diffs.push([currentFile, currentLines.join('\n')]);
49
+ inPatch = false;
50
+ } else if (inPatch) {
51
+ if (stripped.startsWith('*** Update File:')) {
52
+ currentFile = stripped.split(':').slice(1).join(':').trim();
53
+ } else {
54
+ currentLines.push(line);
55
+ }
56
+ }
57
+ }
58
+ return dedupDiffs(diffs);
59
+ }
60
+
61
+ const headerRe = /^(?:diff --git\s+)?(a\/[^ ]+)\s+(b\/[^ ]+)\s*$/;
62
+ const lines = splitLines(diffText);
63
+
64
+ // Check if any header line exists.
65
+ if (!lines.some((line) => headerRe.test(line))) {
66
+ // Fallback strategy: detect file headers from '---' / '+++' pairs.
67
+ /** @type {Array<[string, string]>} */
68
+ const diffs = [];
69
+ let currentLines = [];
70
+ let currentFile = null;
71
+ let deletionMode = false;
72
+ let fromHeader = null;
73
+ // File headers tolerate 2-3 markers because LLMs emit malformed `--`/`++`
74
+ // headers. To avoid misreading a hunk body line that deletes/adds content
75
+ // starting with `-`/`+` (e.g. a markdown list item `- [ ] ...` appears as
76
+ // `-- [ ] ...` in the diff) as a new file header, a `---`/`--` line only
77
+ // counts as a from-header when the *next* line is a `+++`/`++` to-header —
78
+ // unified diffs always pair them. A real deletion body line is followed by
79
+ // an ordinary `+`/context line instead, so it stays in the patch body.
80
+ const headerFromRe = /^-{2,3}\s+(.*)$/;
81
+ const headerToRe = /^\+{2,3}\s+(.*)$/;
82
+
83
+ const stripPrefix = (p) => (p.startsWith('a/') || p.startsWith('b/') ? p.slice(2) : p);
84
+
85
+ for (let idx = 0; idx < lines.length; idx++) {
86
+ const line = lines[idx];
87
+ const fromMatch = line.match(headerFromRe);
88
+ const next = idx + 1 < lines.length ? lines[idx + 1] : null;
89
+ const nextTo = next !== null ? next.match(headerToRe) : null;
90
+
91
+ if (fromMatch && nextTo) {
92
+ if (currentFile !== null && currentLines.length) {
93
+ if (deletionMode && !currentLines.some((l) => l.startsWith('+++ /dev/null'))) {
94
+ currentLines.push('+++ /dev/null');
95
+ }
96
+ diffs.push([currentFile, currentLines.join('\n')]);
97
+ }
98
+ // Start the new file and consume the paired to-header in one step.
99
+ currentLines = [line, next];
100
+ deletionMode = false;
101
+ currentFile = null;
102
+ fromHeader = fromMatch[1].trim();
103
+ const fileTo = nextTo[1].trim();
104
+ if (fileTo === '/dev/null') {
105
+ deletionMode = true;
106
+ if (fromHeader && fromHeader !== '/dev/null') {
107
+ currentFile = stripPrefix(fromHeader);
108
+ }
109
+ } else {
110
+ currentFile = stripPrefix(fileTo);
111
+ }
112
+ idx += 1; // skip the to-header line we just consumed
113
+ continue;
114
+ }
115
+
116
+ currentLines.push(line);
117
+ if (line.includes('deleted file mode')) deletionMode = true;
118
+ }
119
+
120
+ if (currentFile !== null && currentLines.length) {
121
+ if (deletionMode && !currentLines.some((l) => l.startsWith('+++ '))) {
122
+ currentLines.push('+++ /dev/null');
123
+ }
124
+ diffs.push([currentFile, currentLines.join('\n')]);
125
+ }
126
+ return dedupDiffs(diffs);
127
+ }
128
+
129
+ // Header-based strategy.
130
+ /** @type {Array<[string, string]>} */
131
+ const diffs = [];
132
+ let currentLines = [];
133
+ let currentFile = null;
134
+ let deletionMode = false;
135
+ for (const line of lines) {
136
+ const m = line.match(headerRe);
137
+ if (m) {
138
+ if (currentFile !== null && currentLines.length) {
139
+ if (deletionMode && !currentLines.some((l) => l.startsWith('+++ '))) {
140
+ currentLines.push('+++ /dev/null');
141
+ }
142
+ diffs.push([currentFile, currentLines.join('\n')]);
143
+ }
144
+ currentLines = [line];
145
+ deletionMode = false;
146
+ const fileTo = m[2]; // e.g. "b/index.html"
147
+ currentFile = fileTo.startsWith('b/') ? fileTo.slice(2) : fileTo;
148
+ } else {
149
+ currentLines.push(line);
150
+ if (line.includes('deleted file mode')) deletionMode = true;
151
+ if (line.startsWith('+++ ')) {
152
+ const parts = line.split(/\s+/);
153
+ if (parts.length >= 2) {
154
+ const fileTo = parts[1].trim();
155
+ if (fileTo !== '/dev/null') {
156
+ currentFile = fileTo.startsWith('a/') || fileTo.startsWith('b/') ? fileTo.slice(2) : fileTo;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ if (currentFile !== null && currentLines.length) {
163
+ if (deletionMode && !currentLines.some((l) => l.startsWith('+++ '))) {
164
+ currentLines.push('+++ /dev/null');
165
+ }
166
+ diffs.push([currentFile, currentLines.join('\n')]);
167
+ }
168
+ return dedupDiffs(diffs);
169
+ }
170
+
171
+ const HUNK_HEADER_RE = /^@@(?: -(\d+)(?:,(\d+))?)?(?: \+(\d+)(?:,(\d+))?)? @@/;
172
+
173
+ /**
174
+ * Apply a single-file unified diff patch to `originalContent`.
175
+ *
176
+ * Each hunk is located by matching its context+deletion lines against the
177
+ * original rather than trusting the `@@` line numbers, which LLM-generated
178
+ * diffs frequently get slightly wrong. The `@@` start line is used only as a
179
+ * hint to disambiguate between multiple matching positions. The full block of
180
+ * context and deletion lines must match exactly, so a hunk that does not apply
181
+ * cleanly returns `null` instead of silently corrupting the file.
182
+ *
183
+ * @param {string} originalContent
184
+ * @param {string} patch
185
+ * @returns {string | null} the patched content, or null if the patch fails
186
+ */
187
+ export function applyPatchToFile(originalContent, patch) {
188
+ // Work with newline-free lines and re-join with '\n' at the end. This keeps a
189
+ // file's last line (which may lack a trailing newline) on equal footing with
190
+ // the rest, so additions after it do not swallow a blank line.
191
+ const originalLines = splitLines(originalContent || '');
192
+ const patchLines = splitLines(patch);
193
+
194
+ // Parse the patch into hunks: { hint, ops: [{ kind, text }] } where kind is
195
+ // 'context' | 'del' | 'add'.
196
+ const hunks = [];
197
+ let i = 0;
198
+ while (i < patchLines.length) {
199
+ const line = patchLines[i];
200
+ if (line.replace(/^\s+/, '').startsWith('@@')) {
201
+ let origStart;
202
+ let declaredNewCount = null;
203
+ if (line.trim() === '@@') {
204
+ origStart = 1;
205
+ } else {
206
+ const m = line.trim().match(HUNK_HEADER_RE);
207
+ if (!m) return null;
208
+ origStart = m[1] !== undefined ? parseInt(m[1], 10) : 1;
209
+ if (m[3] !== undefined) {
210
+ declaredNewCount = m[4] !== undefined ? parseInt(m[4], 10) : 1;
211
+ }
212
+ }
213
+ /** @type {Array<{kind: string, text: string, noNewline?: boolean}>} */
214
+ const ops = [];
215
+ i += 1;
216
+ while (i < patchLines.length && !patchLines[i].replace(/^\s+/, '').startsWith('@@')) {
217
+ const pline = patchLines[i];
218
+ if (pline.startsWith('\\')) {
219
+ // "" marker — record it on the op it
220
+ // annotates (the previous body line) so the collapse rule below can
221
+ // recognize git's end-of-file newline-change pattern and the final
222
+ // assembly step knows not to append a trailing newline there.
223
+ if (ops.length) ops[ops.length - 1].noNewline = true;
224
+ } else if (pline.startsWith(' ')) {
225
+ ops.push({ kind: 'context', text: pline.slice(1) });
226
+ } else if (pline.startsWith('++') && !pline.startsWith('+++')) {
227
+ // A `++`-prefixed body line is ambiguous: it may be a malformed
228
+ // double-plus addition (LLM emitted `++foo` meaning "add foo") or a
229
+ // legitimate addition of a line whose content itself starts with
230
+ // `+`. Applying either reading risks silent corruption under the
231
+ // other, so refuse and let the caller fall back to the LLM.
232
+ return null;
233
+ } else if (pline.startsWith('-')) {
234
+ ops.push({ kind: 'del', text: pline.slice(1) });
235
+ } else if (pline.startsWith('+')) {
236
+ ops.push({ kind: 'add', text: pline.slice(1) });
237
+ } else {
238
+ // Bare context line without a leading space (LLM-style).
239
+ ops.push({ kind: 'context', text: pline });
240
+ }
241
+ i += 1;
242
+ }
243
+
244
+ // Git's "last line loses its trailing newline" pattern is canonically a
245
+ // del/add pair with identical text plus `\ No newline` markers. Sloppy
246
+ // diffs write the old copy as *context* instead, leaving a trailing
247
+ // `+<line>` that duplicates the last context line:
248
+ // ` const Retries = 3` <- kept line
249
+ // `-const Debug = true` <-
250
+ // `+const Retries = 3` <-
251
+ // Applied literally this duplicates "const Retries = 3". When the final
252
+ // op is such a no-newline add restating the last context line across
253
+ // only deletions, and the @@ header's new-line count confirms the
254
+ // collapsed reading (one line fewer than the literal reading), drop the
255
+ // redundant add — it only re-states the kept line's newline status.
256
+ if (declaredNewCount !== null && ops.length) {
257
+ const last = ops[ops.length - 1];
258
+ if (last.kind === 'add' && last.noNewline) {
259
+ let ctxIdx = -1;
260
+ for (let j = ops.length - 2; j >= 0; j--) {
261
+ if (ops[j].kind === 'context') {
262
+ ctxIdx = j;
263
+ break;
264
+ }
265
+ if (ops[j].kind !== 'del') break; // only deletions may intervene
266
+ }
267
+ if (ctxIdx !== -1 && ctxIdx < ops.length - 2 && ops[ctxIdx].text === last.text) {
268
+ const literalNewCount = ops.filter((o) => o.kind !== 'del').length;
269
+ if (declaredNewCount === literalNewCount - 1) ops.pop();
270
+ }
271
+ }
272
+ }
273
+
274
+ hunks.push({ hint: origStart - 1, ops });
275
+ } else {
276
+ i += 1;
277
+ }
278
+ }
279
+
280
+ // The lines that must already exist in the original, in order, for a hunk.
281
+ const oldBlockOf = (ops) =>
282
+ ops.filter((o) => o.kind !== 'add').map((o) => o.text);
283
+
284
+ // Find where `block` occurs in originalLines at index >= minPos, choosing the
285
+ // occurrence nearest to `hint`. Returns null if there is no exact match.
286
+ const findBlock = (block, minPos, hint) => {
287
+ if (block.length === 0) {
288
+ // Pure-addition hunk (e.g. new file / append): anchor at the hint.
289
+ return Math.min(Math.max(hint, minPos), originalLines.length);
290
+ }
291
+ let best = null;
292
+ for (let p = minPos; p + block.length <= originalLines.length; p++) {
293
+ let matched = true;
294
+ for (let j = 0; j < block.length; j++) {
295
+ if (originalLines[p + j] !== block[j]) {
296
+ matched = false;
297
+ break;
298
+ }
299
+ }
300
+ if (!matched) continue;
301
+ if (best === null || Math.abs(p - hint) < Math.abs(best - hint)) best = p;
302
+ }
303
+ return best;
304
+ };
305
+
306
+ // Track whether the final line pushed to `newLines` should be left without a
307
+ // trailing newline in the reassembled output. This is true either because
308
+ // the patch explicitly marks that line with "",
309
+ // or because the line is carried over verbatim from the original file's own
310
+ // unmodified last line and the original file itself had no trailing newline.
311
+ const originalNoTrailingNewline =
312
+ !!originalContent && originalContent.length > 0 && !/[\n\r]$/.test(originalContent);
313
+ let endsWithoutNewline = false;
314
+
315
+ const newLines = [];
316
+ let cursor = 0;
317
+
318
+ const pushOriginal = (k) => {
319
+ newLines.push(originalLines[k]);
320
+ endsWithoutNewline = k === originalLines.length - 1 && originalNoTrailingNewline;
321
+ };
322
+
323
+ for (const hunk of hunks) {
324
+ const block = oldBlockOf(hunk.ops);
325
+ const pos = findBlock(block, cursor, hunk.hint);
326
+ if (pos === null) return null;
327
+
328
+ // Copy untouched lines between the previous hunk and this one.
329
+ for (let k = cursor; k < pos; k++) pushOriginal(k);
330
+
331
+ let idx = pos;
332
+ for (const op of hunk.ops) {
333
+ if (op.kind === 'add') {
334
+ newLines.push(op.text);
335
+ endsWithoutNewline = !!op.noNewline;
336
+ } else if (op.kind === 'del') {
337
+ idx += 1; // consume the original line without emitting it
338
+ } else {
339
+ pushOriginal(idx); // context: keep the original line
340
+ if (op.noNewline) endsWithoutNewline = true;
341
+ idx += 1;
342
+ }
343
+ }
344
+ cursor = idx;
345
+ }
346
+
347
+ // Append remaining original lines.
348
+ for (let k = cursor; k < originalLines.length; k++) pushOriginal(k);
349
+
350
+ let content = newLines.join('\n');
351
+ if (content && !endsWithoutNewline) content += '\n';
352
+ return content;
353
+ }
354
+
355
+ /**
356
+ * Apply a unified diff to an in-memory file map.
357
+ *
358
+ * @param {Record<string, string>} files map of path -> content
359
+ * @param {string} diffText unified diff string
360
+ * @returns {{ changed: boolean, files: Record<string, string> }}
361
+ * `changed` is true iff at least one file was created, modified, or deleted.
362
+ * `files` is a new map; on failure it equals the input (no partial changes).
363
+ */
364
+ export function applyDiff(files, diffText) {
365
+ const filePatches = parseDiffPerFile(diffText);
366
+ if (!filePatches.length) {
367
+ return { changed: false, files: { ...files } };
368
+ }
369
+
370
+ const original = { ...files };
371
+ const working = { ...files };
372
+
373
+ for (const [filePath, patch] of filePatches) {
374
+ if (patch.includes('+++ /dev/null')) {
375
+ // Deletion patch.
376
+ delete working[filePath];
377
+ } else {
378
+ const result = applyPatchToFile(
379
+ Object.prototype.hasOwnProperty.call(working, filePath) ? working[filePath] : '',
380
+ patch,
381
+ );
382
+ if (result === null) {
383
+ return { changed: false, files: { ...original } };
384
+ }
385
+ working[filePath] = result;
386
+ }
387
+ }
388
+
389
+ // Verify that at least one file actually changed.
390
+ let anyChange = false;
391
+ for (const [filePath, patch] of filePatches) {
392
+ if (patch.includes('+++ /dev/null')) {
393
+ if (!Object.prototype.hasOwnProperty.call(working, filePath)) anyChange = true;
394
+ } else {
395
+ const had = Object.prototype.hasOwnProperty.call(original, filePath);
396
+ if (!had || original[filePath] !== working[filePath]) anyChange = true;
397
+ }
398
+ }
399
+
400
+ if (!anyChange) {
401
+ return { changed: false, files: { ...original } };
402
+ }
403
+ return { changed: true, files: working };
404
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Set an environment-style override (used in the browser where there is no
3
+ * `process.env`). Pass `undefined` to clear an override.
4
+ * @param {string} name
5
+ * @param {string | undefined} value
6
+ */
7
+ export function setEnv(name: string, value: string | undefined): void;
8
+ /**
9
+ * Read an environment variable. Overrides take precedence over `process.env`.
10
+ * @param {string} name
11
+ * @param {string} [fallback]
12
+ * @returns {string | undefined}
13
+ */
14
+ export function getEnv(name: string, fallback?: string): string | undefined;
15
+ export const DEFAULT_MODEL: "xiaomi/mimo-v2.5-pro-ultraspeed";
16
+ export const DEFAULT_BASE_URL: "https://nano-gpt.com/api/v1/";