editbuffer 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal, Mapping
5
+
6
+ from .errors import InvalidOperationError
7
+ from .selection import Selection
8
+
9
+ OperationType = Literal[
10
+ "append",
11
+ "replace",
12
+ "insert_before",
13
+ "insert_after",
14
+ "delete",
15
+ "rollback",
16
+ ]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class EditOperation:
21
+ kind: OperationType
22
+ target: Selection | None = None
23
+ text: str = ""
24
+ version: int | None = None
25
+
26
+ @classmethod
27
+ def from_dict(cls, value: Mapping[str, Any]) -> EditOperation:
28
+ kind = value.get("op")
29
+ if kind not in {
30
+ "append",
31
+ "replace",
32
+ "insert_before",
33
+ "insert_after",
34
+ "delete",
35
+ "rollback",
36
+ }:
37
+ raise InvalidOperationError(f"unknown operation: {kind!r}")
38
+
39
+ if kind == "rollback":
40
+ version = value.get("version")
41
+ if not isinstance(version, int):
42
+ raise InvalidOperationError("'rollback' requires integer 'version'")
43
+ return cls(kind, version=version)
44
+
45
+ if kind != "delete" and "text" not in value:
46
+ raise InvalidOperationError(f"{kind!r} requires 'text'")
47
+ text = value.get("text", "")
48
+ if not isinstance(text, str):
49
+ raise InvalidOperationError("'text' must be a string")
50
+
51
+ if kind == "append":
52
+ return cls(kind, text=text)
53
+
54
+ target = value.get("target")
55
+ if not isinstance(target, Mapping):
56
+ raise InvalidOperationError("'target' must be a selection object")
57
+ return cls(kind, target=Selection.from_dict(target), text=text)
58
+
59
+ def as_dict(self) -> dict[str, Any]:
60
+ if self.kind == "rollback":
61
+ return {"op": "rollback", "version": self.version}
62
+ result: dict[str, Any] = {"op": self.kind}
63
+ if self.kind != "delete":
64
+ result["text"] = self.text
65
+ if self.target is not None:
66
+ result["target"] = self.target.as_dict()
67
+ return result
editbuffer/py.typed ADDED
@@ -0,0 +1 @@
1
+
editbuffer/resolver.py ADDED
@@ -0,0 +1,157 @@
1
+ from dataclasses import dataclass
2
+ from difflib import SequenceMatcher
3
+
4
+ from .errors import (
5
+ AmbiguousTargetError,
6
+ FuzzyMatchError,
7
+ InvalidOperationError,
8
+ StaleVersionError,
9
+ TargetNotFoundError,
10
+ )
11
+ from .selection import Selection
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ResolvedSelection:
16
+ start: int
17
+ end: int
18
+ confidence: float = 1.0
19
+
20
+
21
+ class SelectionResolver:
22
+ def resolve(
23
+ self,
24
+ content: str,
25
+ selection: Selection,
26
+ *,
27
+ version: int,
28
+ ) -> ResolvedSelection:
29
+ if selection.type == "range":
30
+ return self._resolve_range(content, selection, version)
31
+ if selection.type == "block":
32
+ from .blocks import find_blocks
33
+
34
+ if not selection.block_id:
35
+ raise InvalidOperationError("block_id must not be empty")
36
+ return _choose(find_blocks(content, selection.block_id), None)
37
+ if selection.text == "":
38
+ raise InvalidOperationError("selection text must not be empty")
39
+
40
+ if selection.type == "exact":
41
+ candidates = _find_all(content, selection.text or "")
42
+ elif selection.type == "context":
43
+ candidates = _find_context(content, selection)
44
+ else:
45
+ return _find_fuzzy(content, selection)
46
+ return _choose(candidates, selection.occurrence)
47
+
48
+ def _resolve_range(
49
+ self,
50
+ content: str,
51
+ selection: Selection,
52
+ version: int,
53
+ ) -> ResolvedSelection:
54
+ if (
55
+ selection.expected_version is not None
56
+ and selection.expected_version != version
57
+ ):
58
+ raise StaleVersionError(
59
+ f"expected version {selection.expected_version}, current version is {version}"
60
+ )
61
+ start, end = selection.start, selection.end
62
+ if start is None or end is None or start < 0 or end < start or end > len(content):
63
+ raise InvalidOperationError(
64
+ f"invalid range [{start}, {end}) for content length {len(content)}"
65
+ )
66
+ return ResolvedSelection(start, end)
67
+
68
+
69
+ def _find_all(content: str, text: str) -> tuple[ResolvedSelection, ...]:
70
+ matches: list[ResolvedSelection] = []
71
+ start = 0
72
+ while (index := content.find(text, start)) != -1:
73
+ matches.append(ResolvedSelection(index, index + len(text)))
74
+ start = index + 1
75
+ return tuple(matches)
76
+
77
+
78
+ def _find_context(
79
+ content: str,
80
+ selection: Selection,
81
+ ) -> tuple[ResolvedSelection, ...]:
82
+ text = selection.text or ""
83
+ needle = f"{selection.before}{text}{selection.after}"
84
+ offset = len(selection.before)
85
+ return tuple(
86
+ ResolvedSelection(match.start + offset, match.start + offset + len(text))
87
+ for match in _find_all(content, needle)
88
+ )
89
+
90
+
91
+ def _choose(
92
+ candidates: tuple[ResolvedSelection, ...],
93
+ occurrence: int | None,
94
+ ) -> ResolvedSelection:
95
+ if not candidates:
96
+ raise TargetNotFoundError("selection did not match the current buffer")
97
+ if occurrence is not None:
98
+ if occurrence < 0 or occurrence >= len(candidates):
99
+ raise TargetNotFoundError(
100
+ f"occurrence {occurrence} is outside {len(candidates)} matches"
101
+ )
102
+ return candidates[occurrence]
103
+ if len(candidates) > 1:
104
+ raise AmbiguousTargetError(tuple((match.start, match.end) for match in candidates))
105
+ return candidates[0]
106
+
107
+
108
+ def _find_fuzzy(content: str, selection: Selection) -> ResolvedSelection:
109
+ text = selection.text or ""
110
+ if not 0 < selection.threshold <= 1:
111
+ raise InvalidOperationError("fuzzy threshold must be in (0, 1]")
112
+ if not 0 <= selection.ambiguity_margin <= 1:
113
+ raise InvalidOperationError("fuzzy ambiguity_margin must be in [0, 1]")
114
+
115
+ variation = max(2, len(text) // 5)
116
+ raw: list[ResolvedSelection] = []
117
+ for length in range(max(1, len(text) - variation), len(text) + variation + 1):
118
+ for start in range(0, len(content) - length + 1):
119
+ score = SequenceMatcher(None, text, content[start : start + length]).ratio()
120
+ raw.append(ResolvedSelection(start, start + length, score))
121
+
122
+ candidates = _distinct_fuzzy_targets(raw, len(text))
123
+ diagnostics = tuple(
124
+ (candidate.start, candidate.end, round(candidate.confidence, 4))
125
+ for candidate in candidates[:5]
126
+ )
127
+ if not candidates or candidates[0].confidence < selection.threshold:
128
+ raise FuzzyMatchError("below_threshold", diagnostics)
129
+ eligible = [
130
+ candidate
131
+ for candidate in candidates
132
+ if candidate.confidence >= selection.threshold
133
+ ]
134
+ if (
135
+ len(eligible) > 1
136
+ and eligible[0].confidence - eligible[1].confidence
137
+ < selection.ambiguity_margin
138
+ ):
139
+ raise FuzzyMatchError("ambiguous", diagnostics)
140
+ return candidates[0]
141
+
142
+
143
+ def _distinct_fuzzy_targets(
144
+ candidates: list[ResolvedSelection],
145
+ query_length: int,
146
+ ) -> list[ResolvedSelection]:
147
+ ranked = sorted(candidates, key=lambda item: item.confidence, reverse=True)
148
+ selected: list[ResolvedSelection] = []
149
+ separation = max(1, query_length // 2)
150
+ for candidate in ranked:
151
+ center = (candidate.start + candidate.end) // 2
152
+ if all(
153
+ abs(center - (existing.start + existing.end) // 2) >= separation
154
+ for existing in selected
155
+ ):
156
+ selected.append(candidate)
157
+ return selected
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal, Mapping
5
+
6
+ from .errors import InvalidOperationError
7
+
8
+ SelectionType = Literal["exact", "context", "range", "fuzzy", "block"]
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Selection:
13
+ type: SelectionType
14
+ text: str | None = None
15
+ before: str = ""
16
+ after: str = ""
17
+ start: int | None = None
18
+ end: int | None = None
19
+ occurrence: int | None = None
20
+ expected_version: int | None = None
21
+ threshold: float = 0.85
22
+ ambiguity_margin: float = 0.05
23
+ block_id: str | None = None
24
+
25
+ @classmethod
26
+ def exact(cls, text: str, occurrence: int | None = None) -> Selection:
27
+ return cls("exact", text=text, occurrence=occurrence)
28
+
29
+ @classmethod
30
+ def context(
31
+ cls,
32
+ *,
33
+ before: str,
34
+ text: str,
35
+ after: str,
36
+ occurrence: int | None = None,
37
+ ) -> Selection:
38
+ return cls(
39
+ "context",
40
+ text=text,
41
+ before=before,
42
+ after=after,
43
+ occurrence=occurrence,
44
+ )
45
+
46
+ @classmethod
47
+ def range(
48
+ cls,
49
+ start: int,
50
+ end: int,
51
+ *,
52
+ expected_version: int | None = None,
53
+ ) -> Selection:
54
+ return cls(
55
+ "range",
56
+ start=start,
57
+ end=end,
58
+ expected_version=expected_version,
59
+ )
60
+
61
+ @classmethod
62
+ def fuzzy(
63
+ cls,
64
+ text: str,
65
+ *,
66
+ threshold: float = 0.85,
67
+ ambiguity_margin: float = 0.05,
68
+ ) -> Selection:
69
+ return cls(
70
+ "fuzzy",
71
+ text=text,
72
+ threshold=threshold,
73
+ ambiguity_margin=ambiguity_margin,
74
+ )
75
+
76
+ @classmethod
77
+ def block(cls, block_id: str) -> Selection:
78
+ return cls("block", block_id=block_id)
79
+
80
+ @classmethod
81
+ def from_dict(cls, value: Mapping[str, Any]) -> Selection:
82
+ selection_type = value.get("type")
83
+ if selection_type == "exact":
84
+ return cls.exact(
85
+ _required_string(value, "text"),
86
+ occurrence=_optional_int(value, "occurrence"),
87
+ )
88
+ if selection_type == "context":
89
+ return cls.context(
90
+ before=_optional_string(value, "before"),
91
+ text=_required_string(value, "text"),
92
+ after=_optional_string(value, "after"),
93
+ occurrence=_optional_int(value, "occurrence"),
94
+ )
95
+ if selection_type == "range":
96
+ return cls.range(
97
+ _required_int(value, "start"),
98
+ _required_int(value, "end"),
99
+ expected_version=_optional_int(value, "expected_version"),
100
+ )
101
+ if selection_type == "fuzzy":
102
+ return cls.fuzzy(
103
+ _required_string(value, "text"),
104
+ threshold=_optional_float(value, "threshold", 0.85),
105
+ ambiguity_margin=_optional_float(
106
+ value,
107
+ "ambiguity_margin",
108
+ 0.05,
109
+ ),
110
+ )
111
+ if selection_type == "block":
112
+ return cls.block(_required_string(value, "block_id"))
113
+ raise InvalidOperationError(f"unknown selection type: {selection_type!r}")
114
+
115
+ def as_dict(self) -> dict[str, Any]:
116
+ if self.type == "exact":
117
+ result: dict[str, Any] = {"type": "exact", "text": self.text}
118
+ if self.occurrence is not None:
119
+ result["occurrence"] = self.occurrence
120
+ return result
121
+ if self.type == "context":
122
+ result = {
123
+ "type": "context",
124
+ "before": self.before,
125
+ "text": self.text,
126
+ "after": self.after,
127
+ }
128
+ if self.occurrence is not None:
129
+ result["occurrence"] = self.occurrence
130
+ return result
131
+ if self.type == "range":
132
+ result = {"type": "range", "start": self.start, "end": self.end}
133
+ if self.expected_version is not None:
134
+ result["expected_version"] = self.expected_version
135
+ return result
136
+ if self.type == "fuzzy":
137
+ return {
138
+ "type": "fuzzy",
139
+ "text": self.text,
140
+ "threshold": self.threshold,
141
+ "ambiguity_margin": self.ambiguity_margin,
142
+ }
143
+ return {"type": "block", "block_id": self.block_id}
144
+
145
+
146
+ def _required_string(value: Mapping[str, Any], key: str) -> str:
147
+ result = value.get(key)
148
+ if not isinstance(result, str):
149
+ raise InvalidOperationError(f"{key!r} must be a string")
150
+ return result
151
+
152
+
153
+ def _optional_string(value: Mapping[str, Any], key: str) -> str:
154
+ result = value.get(key, "")
155
+ if not isinstance(result, str):
156
+ raise InvalidOperationError(f"{key!r} must be a string")
157
+ return result
158
+
159
+
160
+ def _required_int(value: Mapping[str, Any], key: str) -> int:
161
+ result = value.get(key)
162
+ if not isinstance(result, int):
163
+ raise InvalidOperationError(f"{key!r} must be an integer")
164
+ return result
165
+
166
+
167
+ def _optional_int(value: Mapping[str, Any], key: str) -> int | None:
168
+ result = value.get(key)
169
+ if result is not None and not isinstance(result, int):
170
+ raise InvalidOperationError(f"{key!r} must be an integer")
171
+ return result
172
+
173
+
174
+ def _optional_float(
175
+ value: Mapping[str, Any],
176
+ key: str,
177
+ default: float,
178
+ ) -> float:
179
+ result = value.get(key, default)
180
+ if not isinstance(result, (int, float)):
181
+ raise InvalidOperationError(f"{key!r} must be a number")
182
+ return float(result)
@@ -0,0 +1,23 @@
1
+ import json
2
+ import subprocess
3
+
4
+ from .errors import ValidationError
5
+
6
+
7
+ def valid_json(content: str) -> None:
8
+ try:
9
+ json.loads(content)
10
+ except json.JSONDecodeError as error:
11
+ raise ValidationError(str(error)) from error
12
+
13
+
14
+ def valid_shell(content: str) -> None:
15
+ result = subprocess.run(
16
+ ["sh", "-n"],
17
+ input=content,
18
+ text=True,
19
+ capture_output=True,
20
+ check=False,
21
+ )
22
+ if result.returncode:
23
+ raise ValidationError(result.stderr.strip() or "invalid shell syntax")
@@ -0,0 +1,267 @@
1
+ Metadata-Version: 2.4
2
+ Name: editbuffer
3
+ Version: 0.2.1
4
+ Summary: Selection-based mutable output buffer for LLM tools
5
+ Author: averagedigital
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Provides-Extra: mcp
16
+ Requires-Dist: mcp<2,>=1.27; extra == "mcp"
17
+ Dynamic: license-file
18
+
19
+ # editbuffer
20
+
21
+ `editbuffer` is a small Python runtime for editing an LLM's pending text output
22
+ before it is committed.
23
+
24
+ The public abstraction is selection-based editing, not file patches:
25
+
26
+ - select exact, contextual, fuzzy, block, or character-range targets;
27
+ - replace, insert before/after, or delete the selection;
28
+ - reject missing, ambiguous, invalid, and stale selections;
29
+ - inspect the current buffer and its audit history;
30
+ - commit the final artifact.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install editbuffer
36
+ ```
37
+
38
+ For local development:
39
+
40
+ ```bash
41
+ python -m pip install -e .
42
+ ```
43
+
44
+ Python 3.11+ is required. The core library has no runtime dependencies.
45
+
46
+ ## Quickstart
47
+
48
+ ```python
49
+ from editbuffer import EditBuffer, Selection
50
+
51
+ buf = EditBuffer()
52
+ buf.append('find . -name "*.py" -exec grep -n "TODO" {} ;')
53
+ buf.replace(Selection.exact("{} ;"), "{} \\;")
54
+
55
+ final = buf.commit()
56
+ ```
57
+
58
+ Selections can also be dictionaries:
59
+
60
+ ```python
61
+ buf.replace(
62
+ {
63
+ "type": "context",
64
+ "before": 'grep -n "TODO" ',
65
+ "text": "{} ;",
66
+ "after": "",
67
+ },
68
+ "{} \\;",
69
+ )
70
+ ```
71
+
72
+ ## LLM tool-call format
73
+
74
+ ```json
75
+ {
76
+ "op": "replace",
77
+ "target": {
78
+ "type": "context",
79
+ "before": "grep -n \"TODO\" ",
80
+ "text": "{} ;",
81
+ "after": ""
82
+ },
83
+ "text": "{} \\;"
84
+ }
85
+ ```
86
+
87
+ Pass the decoded object to `buffer.apply(operation)`. Supported operations are
88
+ `append`, `replace`, `insert_before`, `insert_after`, and `delete`.
89
+
90
+ Range selections use half-open character offsets and can reject stale edits:
91
+
92
+ ```json
93
+ {
94
+ "op": "delete",
95
+ "target": {
96
+ "type": "range",
97
+ "start": 10,
98
+ "end": 20,
99
+ "expected_version": 3
100
+ }
101
+ }
102
+ ```
103
+
104
+ ## Failure behavior
105
+
106
+ - no match raises `TargetNotFoundError`;
107
+ - multiple matches raise `AmbiguousTargetError` with candidate ranges;
108
+ - `occurrence` can explicitly choose a zero-based match;
109
+ - invalid ranges or operations raise `InvalidOperationError`;
110
+ - a mismatched `expected_version` raises `StaleVersionError`;
111
+ - validator failures do not mutate text or history;
112
+ - fuzzy matching is opt-in and rejects low-confidence or competing candidates.
113
+
114
+ ## Fuzzy and block selections
115
+
116
+ Fuzzy selection uses the standard library and records the accepted confidence:
117
+
118
+ ```python
119
+ buf.replace(
120
+ Selection.fuzzy(
121
+ "run integrtion tests",
122
+ threshold=0.85,
123
+ ambiguity_margin=0.05,
124
+ ),
125
+ "run unit tests",
126
+ )
127
+ ```
128
+
129
+ It never runs as a fallback for exact/context selection. If no candidate meets
130
+ the threshold, or two candidates are too close, `FuzzyMatchError` includes
131
+ candidate ranges and scores.
132
+
133
+ Block selection requires an explicit ID. Fenced code blocks use:
134
+
135
+ ````markdown
136
+ ```python editbuffer:id=setup
137
+ print("old")
138
+ ```
139
+ ````
140
+
141
+ Markdown regions use:
142
+
143
+ ```markdown
144
+ <!-- editbuffer:block summary -->
145
+ old content
146
+ <!-- /editbuffer:block -->
147
+ ```
148
+
149
+ `Selection.block("setup")` selects the content inside the markers, preserving
150
+ the fence or comments. Duplicate IDs are rejected as ambiguous.
151
+
152
+ ## Snapshots and rollback
153
+
154
+ Every successful edit stores an in-memory snapshot:
155
+
156
+ ```python
157
+ buf.append("draft")
158
+ version = buf.version
159
+ buf.replace(Selection.exact("draft"), "final")
160
+ buf.rollback(version)
161
+ ```
162
+
163
+ Rollback restores the snapshot as a new audited version. Snapshots live only
164
+ for the lifetime of the `EditBuffer` process.
165
+
166
+ ## Optional validators
167
+
168
+ ```python
169
+ from editbuffer import EditBuffer
170
+ from editbuffer.validators import valid_json, valid_shell
171
+
172
+ json_buffer = EditBuffer('{"ok": true}', validators=(valid_json,))
173
+ shell_buffer = EditBuffer("echo ok", validators=(valid_shell,))
174
+ ```
175
+
176
+ `valid_shell` invokes the local POSIX `sh -n` parser without executing the
177
+ command.
178
+
179
+ ## CLI
180
+
181
+ The CLI stores an operation log in a JSON state file:
182
+
183
+ ```bash
184
+ editbuffer new /tmp/output.json --text "hello world"
185
+ editbuffer replace /tmp/output.json '{"type":"exact","text":"world"}' "there"
186
+ editbuffer view /tmp/output.json
187
+ editbuffer history /tmp/output.json
188
+ editbuffer rollback /tmp/output.json 1
189
+ editbuffer commit /tmp/output.json
190
+ ```
191
+
192
+ Use `editbuffer apply STATE OPERATION_JSON` for the complete operation schema.
193
+
194
+ ## MCP / agent integration
195
+
196
+ Install the optional server:
197
+
198
+ ```bash
199
+ pipx install 'editbuffer[mcp]'
200
+ ```
201
+
202
+ Until a PyPI release exists, install directly from GitHub:
203
+
204
+ ```bash
205
+ pipx install 'editbuffer[mcp] @ git+https://github.com/averagedigital/editbuffer.git'
206
+ ```
207
+
208
+ Connect it to Codex:
209
+
210
+ ```bash
211
+ codex mcp add editbuffer -- editbuffer-mcp
212
+ ```
213
+
214
+ Codex supports local STDIO MCP servers configured with `codex mcp add`; use
215
+ `/mcp` in the Codex terminal UI to confirm the server is active.
216
+
217
+ Claude Desktop and generic MCP client examples are in
218
+ [`docs/mcp.md`](docs/mcp.md).
219
+
220
+ The server exposes:
221
+
222
+ - `buffer_create`
223
+ - `buffer_list`
224
+ - `buffer_view`
225
+ - `buffer_edit`
226
+ - `buffer_history`
227
+ - `buffer_rollback`
228
+ - `buffer_commit`
229
+ - `command_history`
230
+ - `command_select`
231
+
232
+ Buffers are in-memory and live for the MCP server process. The MCP layer calls
233
+ the same core API and does not implement separate edit semantics.
234
+
235
+ `buffer_commit` remembers non-empty committed output as a reusable command.
236
+ `command_history` returns the last 10 commands, newest first. `command_select`
237
+ creates a new pending buffer from a previous command so the model can reuse it
238
+ instead of regenerating it.
239
+
240
+ ## Examples
241
+
242
+ - [`examples/shell_repair.py`](examples/shell_repair.py)
243
+ - [`examples/json_repair.py`](examples/json_repair.py)
244
+ - [`examples/chat_restructure.py`](examples/chat_restructure.py)
245
+
246
+ ## Scope
247
+
248
+ This project is not:
249
+
250
+ - an agent framework;
251
+ - a retrieval system or repo indexer;
252
+ - a full IDE;
253
+ - a replacement for coding agents;
254
+ - a generic output quality evaluator.
255
+
256
+ Persistent storage, atomic operation batches, TypeScript bindings, and
257
+ syntax-aware block parsing are future work.
258
+
259
+ ## Development
260
+
261
+ ```bash
262
+ PYTHONPATH=src python -m unittest discover -s tests -v
263
+ ```
264
+
265
+ ## License
266
+
267
+ MIT