patchling 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -8
- package/package.json +6 -4
- package/src/applydiff.d.ts +69 -0
- package/src/applydiff.js +782 -0
- package/src/filter.d.ts +366 -0
- package/src/filter.js +1513 -0
- package/src/gptdiff.d.ts +28 -1
- package/src/gptdiff.js +78 -10
- package/src/index.d.ts +3 -0
- package/src/index.js +25 -0
- package/src/llm.d.ts +4 -1
- package/src/llm.js +4 -0
package/README.md
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
|
|
9
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
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
|
|
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 patchling](https://github.com/255BITS/patchling-py) (formerly gptdiff); it operates on an in-memory file map instead of a directory on disk.
|
|
12
12
|
|
|
13
|
-
See it in action: **[live browser demos →](https://255bits.github.io/
|
|
13
|
+
See it in action: **[patchling.app](https://patchling.app)** (live demo on the homepage) · **[live browser demos →](https://255bits.github.io/patchling-examples/)**
|
|
14
14
|
|
|
15
15
|
> **The family** —
|
|
16
|
-
> [**
|
|
17
|
-
> **patchling** (this package — the browser/Node runtime) ·
|
|
16
|
+
> [**patchling.app**](https://patchling.app) (project home + live demo) ·
|
|
17
|
+
> **patchling** (this package — the browser/Node runtime, [npm](https://www.npmjs.com/package/patchling)) ·
|
|
18
|
+
> [**patchling for Python**](https://github.com/255BITS/patchling-py) (library + CLI, [PyPI](https://pypi.org/project/patchling/)) ·
|
|
18
19
|
> [**nanoodle.com**](https://nanoodle.com) (visual AI workflow editor built on it)
|
|
19
20
|
|
|
20
21
|
```js
|
|
@@ -40,7 +41,7 @@ npm install patchling
|
|
|
40
41
|
import { generateDiff, smartapply } from 'patchling';
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
**Browser, no build step** — it's zero-dependency ESM, so a CDN import works directly
|
|
44
|
+
**Browser, no build step** — it's zero-dependency ESM, so a CDN import works directly:
|
|
44
45
|
|
|
45
46
|
```js
|
|
46
47
|
import { generateDiff, smartapply } from 'https://esm.sh/patchling';
|
|
@@ -63,6 +64,7 @@ Configuration comes from environment variables (Node) or `setEnv(...)` overrides
|
|
|
63
64
|
| `GPTDIFF_LLM_API_KEY` | NanoGPT API key (`sk-nano-…`) | — (required for live calls) |
|
|
64
65
|
| `GPTDIFF_LLM_BASE_URL` | OpenAI-compatible base URL | `https://nano-gpt.com/api/v1/` |
|
|
65
66
|
| `GPTDIFF_MODEL` | Model id | `xiaomi/mimo-v2.5-pro-ultraspeed` |
|
|
67
|
+
| `GPTDIFF_FILTER_MODEL` | Cheap model for `filterRelevant` | `xiaomi/mimo-v2.6-flash` |
|
|
66
68
|
|
|
67
69
|
```js
|
|
68
70
|
import { setEnv } from 'patchling';
|
|
@@ -100,6 +102,49 @@ Builds the prompt, calls the LLM, and returns the unified diff extracted from th
|
|
|
100
102
|
- `opts.callLlm` — inject a custom/mock completion client (used heavily in tests).
|
|
101
103
|
- `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
104
|
|
|
105
|
+
### `filterRelevant(files, task, opts?) → Promise<Result>`
|
|
106
|
+
Opt-in pass for a large file map. Each candidate file is sent with the task to a cheap model that answers `is this file relevant to the task? yes/no`. `result.files` is the `yes` map, in input order, with the original contents. Hand that to `generateDiff`. `smartapply` still takes the full map, so files the diff never mentions are left as they were.
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
import { filterRelevant, buildEnvironment, generateDiff, smartapply } from 'patchling';
|
|
110
|
+
|
|
111
|
+
const result = await filterRelevant(files, task, {
|
|
112
|
+
// model: 'xiaomi/mimo-v2.6-flash', // GPTDIFF_FILTER_MODEL, else this default
|
|
113
|
+
concurrency: 8, // max in-flight calls
|
|
114
|
+
// cache: null, // omit for a fresh in-memory cache; a path string persists it
|
|
115
|
+
// maxBytes: 200000, // 0 or null disables
|
|
116
|
+
// maxTokens: 12000, // input-token cap; 0 or null disables
|
|
117
|
+
// onOversize: 'truncate', // include | exclude | truncate
|
|
118
|
+
// onBinary: 'exclude', // include | exclude | truncate
|
|
119
|
+
onDecision(d) { console.log(d.path, d.decision); },
|
|
120
|
+
});
|
|
121
|
+
const diff = await generateDiff(buildEnvironment(result.files), task, { model });
|
|
122
|
+
const next = await smartapply(diff, files);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- `opts.model`, `opts.apiKey`, `opts.baseUrl` — same key and base URL resolution as `generateDiff` (`GPTDIFF_LLM_API_KEY`, `GPTDIFF_LLM_BASE_URL`). The model falls back to `GPTDIFF_FILTER_MODEL`, then `xiaomi/mimo-v2.6-flash`.
|
|
126
|
+
- `opts.concurrency` — at most this many classifications in flight (default 8).
|
|
127
|
+
- `opts.cache` — a `RelevanceCache`, a `{ get, set }` object, or a filesystem path (Node writes `{ version: 1, entries }` atomically; a corrupt file is ignored). Omit it, or pass `true`, for a fresh in-memory cache used only by this call. `null` and `false` turn caching off. `createMemoryCache()` builds another memory cache you can share. The key is the SHA-256 of the length-prefixed classification inputs. Each field is written as its UTF-8 byte length, a colon, the bytes, and a newline, in this order: prompt version (`3`), model, output-token limit, the reasoning effort actually sent (`""` when that parameter was omitted or rejected), task, file path, mode (`full`, `truncated`, or `binary`), the preview note (or `""`), and the body that was sent. A later lookup tries the configured effort first, then the empty effort. A full file and a preview truncated to the same bytes do not share a key. Each path is its own decision, including paths with identical contents. A cache read failure is a miss. A cache write failure is logged and the run continues. Fallback and error decisions are not stored.
|
|
128
|
+
- `opts.maxBytes` — UTF-8 size cap (default 200000). `opts.maxTokens` — input-token cap (default 12000; `ceil(code points / 4)`, so a surrogate pair is one code point). `0` or `null` disables either limit. Token truncation keeps the first `maxTokens * 4` code points. Byte truncation stays on a UTF-8 code-point boundary.
|
|
129
|
+
- `opts.onOversize` and `opts.onBinary` — `include` (keep, no model call), `exclude` (drop, no model call), or `truncate` (ask about a preview). Defaults are `truncate` and `exclude`. A `yes` on a truncated preview still forwards the original text in `result.files`. An included binary file keeps the original bytes.
|
|
130
|
+
- `opts.ignore` — lockfiles and generated paths are excluded with `skippedReason: "ignored"`. Matching is case-insensitive. `**` crosses directories and `*` does not. `false` classifies every path. An array replaces the default list. The canonical list is `DEFAULT_IGNORE_PATTERNS`: `**/package-lock.json`, `**/yarn.lock`, `**/pnpm-lock.yaml`, `**/bun.lock`, `**/bun.lockb`, `**/npm-shrinkwrap.json`, `**/Cargo.lock`, `**/Gemfile.lock`, `**/poetry.lock`, `**/Pipfile.lock`, `**/composer.lock`, `**/go.sum`, `**/uv.lock`, `**/pdm.lock`, `**/flake.lock`, `**/pubspec.lock`, `**/packages.lock.json`, `**/Package.resolved`, `**/*.min.js`, `**/*.min.mjs`, `**/*.min.css`, `**/*.map`, `**/dist/**`, `**/build/**`, `**/.next/**`, `**/coverage/**`, `**/__pycache__/**`, `**/*.pyc`, `**/*.pyo`, `**/*.generated.*`. The same list is in `test/fixtures/parity/relevance.json`.
|
|
131
|
+
- `opts.maxOutputTokens` — completion cap (default 1024). The call also sends `reasoning_effort: "low"` so a reasoning model does not spend the whole budget before the yes/no. A 4xx that names that parameter is retried once without it. An empty answer is an error.
|
|
132
|
+
- `opts.retries` — extra attempts after a timeout, an HTTP 5xx (including 503), or a billing-reservation error (default 2), with exponential backoff.
|
|
133
|
+
- `opts.onDecision` — called as each file finishes, in completion order. `result.decisions` stays in input order.
|
|
134
|
+
- `opts.callLlm` — inject a mock client.
|
|
135
|
+
|
|
136
|
+
Each decision is `{ path, decision, reason, raw, tokens, promptTokens, completionTokens, cached, skippedReason, error }`.
|
|
137
|
+
|
|
138
|
+
- `decision` is `"yes"` or `"no"`. After `<think>` / `<thinking>` blocks are stripped, the decision is a leading standalone `yes` or `no` (quotes, markdown bold, backticks, and an `Answer:` or `Decision:` prefix are wrappers, including when a reason follows, as in `**no** unrelated`). Any other wording, including a negated "no" later in the sentence, is malformed: the file is included, `failedOpen` is true, and the judgment is not cached. An empty answer, a non-string message content, and a thrown call do the same. `error` is set when the call throws or the answer is empty. `result.failedOpen` counts those files, and `formatFilterSummary` includes that count.
|
|
139
|
+
- `skippedReason` is `"binary"`, `"oversize"`, `"truncated"`, `"ignored"`, or `null`.
|
|
140
|
+
- `tokens`, `promptTokens`, and `completionTokens` are `0` when no model call was made. `reason` is a string. `failedOpen` is true only when the file was included without a definitive yes/no.
|
|
141
|
+
- `result.billedTokens` sums token counts for decisions that were not cache hits. Identical contents at different paths are separate calls.
|
|
142
|
+
- `buildEnvironment(files, { decisions })` keeps only `"yes"` paths. Passing the decisions array itself does the same. With no second argument, every file is included. `buildEnvironment(result.files)` is the same set.
|
|
143
|
+
|
|
144
|
+
Python (`filter_relevant` in [patchling-py](https://github.com/255BITS/patchling-py)) returns the same result. Its fields are snake_case: `max_bytes`, `max_tokens`, `on_oversize`, `on_binary`, `skipped_reason`, `prompt_tokens`, `completion_tokens`, `failed_open`. Prompt version 3, the prompt text, the cache-key encoding, the ignore list, and the code-point token estimate are the shared contract in `test/fixtures/parity/relevance.json`. The whole byte payload is validated as UTF-8. A leading BOM (U+FEFF) is kept. Invalid bytes anywhere take the binary path (an incomplete sequence is tolerated only on the 8192-byte probe boundary). A response that is not a chat-completion envelope, including non-string message content, fails that file open and is not cached.
|
|
145
|
+
|
|
146
|
+
A pallets/flask labeling pass has been described as recall 1.0 for `xiaomi/mimo-v2.6-flash`, with the filter using about 0.8× the tokens of a full-context read. Those figures are **UNVERIFIED**.
|
|
147
|
+
|
|
103
148
|
### `smartapply(diffText, files, opts?) → Promise<{ path: content }>`
|
|
104
149
|
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
150
|
|
|
@@ -111,14 +156,14 @@ Applies a diff to an in-memory file map with per-file, LLM-assisted conflict res
|
|
|
111
156
|
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
157
|
|
|
113
158
|
### Other exports
|
|
114
|
-
`parseDiffPerFile`, `buildEnvironment`, `colorCodeDiff`, `swallowReasoning`, `stripBadOutput`, `extractDiffBlocks`, `callLlm`, `resolveApiKey`, `resolveBaseUrl`, `getEnv`, `setEnv`, and the `oauth` namespace.
|
|
159
|
+
`parseDiffPerFile`, `buildEnvironment`, `filterRelevant`, `RelevanceCache`, `createMemoryCache`, `parseRelevanceAnswer`, `isBinaryContent`, `isIgnoredPath`, `countTokens`, `formatFilterSummary`, `dumpDecisions`, `PROMPT_VERSION`, `colorCodeDiff`, `swallowReasoning`, `stripBadOutput`, `extractDiffBlocks`, `callLlm`, `resolveApiKey`, `resolveBaseUrl`, `getEnv`, `setEnv`, and the `oauth` namespace.
|
|
115
160
|
|
|
116
161
|
## Differences from the Python package
|
|
117
162
|
|
|
118
163
|
- **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 `
|
|
164
|
+
- **Scope.** Only `generateDiff` + `smartapply` (and their dependencies) are ported — not the Python package's CLIs (`patchling`, `patchling-apply`).
|
|
120
165
|
- **Dependency injection** replaces Python's `monkeypatch`: pass `callLlm` / `callLlmForApply` to test without a network.
|
|
121
|
-
- **`fetch` + Web Crypto** replace `openai`/`requests`/`tiktoken`.
|
|
166
|
+
- **`fetch` + Web Crypto** replace `openai`/`requests`/`tiktoken`. Relevance token counts use the same characters/4 estimate Python uses when tiktoken is absent. A disk-backed `RelevanceCache` is Node-only; the browser keeps the in-memory cache.
|
|
122
167
|
|
|
123
168
|
## License
|
|
124
169
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "patchling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -25,16 +25,18 @@
|
|
|
25
25
|
"sideEffects": false,
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
28
|
-
"url": "git+https://github.com/255BITS/
|
|
28
|
+
"url": "git+https://github.com/255BITS/patchling.git"
|
|
29
29
|
},
|
|
30
|
-
"homepage": "https://github.com/255BITS/
|
|
30
|
+
"homepage": "https://github.com/255BITS/patchling#readme",
|
|
31
31
|
"bugs": {
|
|
32
|
-
"url": "https://github.com/255BITS/
|
|
32
|
+
"url": "https://github.com/255BITS/patchling/issues"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"prepublishOnly": "npm test",
|
|
36
36
|
"test": "node --test",
|
|
37
37
|
"test:live": "GPTDIFF_LIVE=1 node --test test/live.test.js",
|
|
38
|
+
"test:live:llm": "LIVE_LLM=1 node --test test/live-smartapply.test.js",
|
|
39
|
+
"test:live:filter": "LIVE_LLM=1 node --test test/live-filter.test.js",
|
|
38
40
|
"benchmark": "node benchmark/run.js",
|
|
39
41
|
"types": "rm -f src/*.d.ts && tsc -p tsconfig.json"
|
|
40
42
|
},
|
package/src/applydiff.d.ts
CHANGED
|
@@ -37,3 +37,72 @@ export function applyDiff(files: Record<string, string>, diffText: string): {
|
|
|
37
37
|
changed: boolean;
|
|
38
38
|
files: Record<string, string>;
|
|
39
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Parse the hunks of a single-file patch.
|
|
42
|
+
*
|
|
43
|
+
* Returns null when a body line is ambiguous (`++`, which is either a doubled
|
|
44
|
+
* add marker or a literal line that starts with `+`). A non-standard hunk
|
|
45
|
+
* header with no line numbers is kept and anchored at the start of the file.
|
|
46
|
+
* {@link applyPatchToFile} refuses that header instead; this parser exists so
|
|
47
|
+
* a later intent check can still see the `+`/`-` lines.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} patch
|
|
50
|
+
* @returns {Array<{ hint: number, ops: Array<{ kind: string, text: string }> }> | null}
|
|
51
|
+
*/
|
|
52
|
+
export function parsePatchHunks(patch: string): Array<{
|
|
53
|
+
hint: number;
|
|
54
|
+
ops: Array<{
|
|
55
|
+
kind: string;
|
|
56
|
+
text: string;
|
|
57
|
+
}>;
|
|
58
|
+
}> | null;
|
|
59
|
+
/**
|
|
60
|
+
* Whether `updatedContent` does something other than apply this diff's
|
|
61
|
+
* `+`/`-` lines to `originalContent`.
|
|
62
|
+
*
|
|
63
|
+
* Armed when a context line does not occur anywhere in the original file —
|
|
64
|
+
* the case where a model copies the diff's stale context in, or drops lines
|
|
65
|
+
* the hunk never removed. A context line that differs only by a uniform
|
|
66
|
+
* indentation shift, or a hunk that matches somewhere other than its `@@`
|
|
67
|
+
* line, still counts as context: the edit is accepted at the file's indent.
|
|
68
|
+
* A result that deletes any other line, or an empty rewrite of a non-empty
|
|
69
|
+
* file, is still a violation.
|
|
70
|
+
*
|
|
71
|
+
* A `-` line that merely fails to match (quotes, whitespace) does not by
|
|
72
|
+
* itself arm the check when every context line is present as written, so a
|
|
73
|
+
* fuzzy model rewrite of that line can still stand.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} originalContent
|
|
76
|
+
* @param {string} updatedContent
|
|
77
|
+
* @param {string} patch
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function diffOutputViolatesIntent(originalContent: string, updatedContent: string, patch: string): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Apply only the `+`/`-` lines of a patch, ignoring context lines that are
|
|
83
|
+
* not actually in the file.
|
|
84
|
+
*
|
|
85
|
+
* A context or `-` line may match with a uniform indentation shift, and the
|
|
86
|
+
* hunk may sit somewhere other than the hunk header. Added lines are written
|
|
87
|
+
* at that same indent. When drifted context pairs a diff name with the
|
|
88
|
+
* file's own name (`user_id` in the diff, `uid` in the file), `+` lines are
|
|
89
|
+
* rewritten onto those names. Returns null when a `-` line cannot be found
|
|
90
|
+
* even then, when a pure insertion has no matching context to anchor it, or
|
|
91
|
+
* when a body line is ambiguous. Never writes a context line that was not
|
|
92
|
+
* already in the original.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} originalContent
|
|
95
|
+
* @param {string} patch
|
|
96
|
+
* @returns {string | null}
|
|
97
|
+
*/
|
|
98
|
+
export function applyIntentIgnoringBadContext(originalContent: string, patch: string): string | null;
|
|
99
|
+
/**
|
|
100
|
+
* Deterministic +/- apply that rewrote at least one added identifier onto
|
|
101
|
+
* a name the file already uses. Null when the hunk cannot be anchored or
|
|
102
|
+
* when no identifier changed, so the caller can still ask the model.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} originalContent
|
|
105
|
+
* @param {string} patch
|
|
106
|
+
* @returns {string | null}
|
|
107
|
+
*/
|
|
108
|
+
export function applyAdaptedIdentifiers(originalContent: string, patch: string): string | null;
|