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.
- editbuffer/__init__.py +30 -0
- editbuffer/blocks.py +41 -0
- editbuffer/buffer.py +178 -0
- editbuffer/cli.py +138 -0
- editbuffer/errors.py +35 -0
- editbuffer/history.py +33 -0
- editbuffer/mcp_server.py +205 -0
- editbuffer/operations.py +67 -0
- editbuffer/py.typed +1 -0
- editbuffer/resolver.py +157 -0
- editbuffer/selection.py +182 -0
- editbuffer/validators.py +23 -0
- editbuffer-0.2.1.dist-info/METADATA +267 -0
- editbuffer-0.2.1.dist-info/RECORD +18 -0
- editbuffer-0.2.1.dist-info/WHEEL +5 -0
- editbuffer-0.2.1.dist-info/entry_points.txt +3 -0
- editbuffer-0.2.1.dist-info/licenses/LICENSE +21 -0
- editbuffer-0.2.1.dist-info/top_level.txt +1 -0
editbuffer/operations.py
ADDED
|
@@ -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
|
editbuffer/selection.py
ADDED
|
@@ -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)
|
editbuffer/validators.py
ADDED
|
@@ -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
|