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/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
|
+
};
|
package/src/applydiff.js
ADDED
|
@@ -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/";
|