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/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .buffer import EditBuffer
|
|
2
|
+
from .errors import (
|
|
3
|
+
AmbiguousTargetError,
|
|
4
|
+
EditBufferError,
|
|
5
|
+
FuzzyMatchError,
|
|
6
|
+
InvalidOperationError,
|
|
7
|
+
StaleVersionError,
|
|
8
|
+
TargetNotFoundError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
)
|
|
11
|
+
from .history import EditHistory, EditRecord
|
|
12
|
+
from .operations import EditOperation
|
|
13
|
+
from .resolver import SelectionResolver
|
|
14
|
+
from .selection import Selection
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AmbiguousTargetError",
|
|
18
|
+
"EditBuffer",
|
|
19
|
+
"EditBufferError",
|
|
20
|
+
"EditHistory",
|
|
21
|
+
"EditOperation",
|
|
22
|
+
"EditRecord",
|
|
23
|
+
"FuzzyMatchError",
|
|
24
|
+
"InvalidOperationError",
|
|
25
|
+
"Selection",
|
|
26
|
+
"SelectionResolver",
|
|
27
|
+
"StaleVersionError",
|
|
28
|
+
"TargetNotFoundError",
|
|
29
|
+
"ValidationError",
|
|
30
|
+
]
|
editbuffer/blocks.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .resolver import ResolvedSelection
|
|
4
|
+
|
|
5
|
+
_FENCE = re.compile(
|
|
6
|
+
r"^(?P<fence>`{3,}|~{3,})[^\n]*\beditbuffer:id=(?P<id>[A-Za-z0-9_.-]+)[^\n]*\n",
|
|
7
|
+
re.MULTILINE,
|
|
8
|
+
)
|
|
9
|
+
_REGION = re.compile(
|
|
10
|
+
r"^<!--\s*editbuffer:block\s+(?P<id>[A-Za-z0-9_.-]+)\s*-->\s*\n",
|
|
11
|
+
re.MULTILINE,
|
|
12
|
+
)
|
|
13
|
+
_REGION_END = re.compile(r"^<!--\s*/editbuffer:block\s*-->", re.MULTILINE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_blocks(content: str, block_id: str) -> tuple[ResolvedSelection, ...]:
|
|
17
|
+
matches = list(_fenced_blocks(content, block_id))
|
|
18
|
+
matches.extend(_regions(content, block_id))
|
|
19
|
+
return tuple(sorted(matches, key=lambda match: match.start))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fenced_blocks(content: str, block_id: str):
|
|
23
|
+
for opening in _FENCE.finditer(content):
|
|
24
|
+
if opening.group("id") != block_id:
|
|
25
|
+
continue
|
|
26
|
+
fence = re.escape(opening.group("fence"))
|
|
27
|
+
closing = re.search(rf"^{fence}[^\n]*(?:\n|$)", content[opening.end() :], re.MULTILINE)
|
|
28
|
+
if closing:
|
|
29
|
+
yield ResolvedSelection(
|
|
30
|
+
opening.end(),
|
|
31
|
+
opening.end() + closing.start(),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _regions(content: str, block_id: str):
|
|
36
|
+
for opening in _REGION.finditer(content):
|
|
37
|
+
if opening.group("id") != block_id:
|
|
38
|
+
continue
|
|
39
|
+
closing = _REGION_END.search(content, opening.end())
|
|
40
|
+
if closing:
|
|
41
|
+
yield ResolvedSelection(opening.end(), closing.start())
|
editbuffer/buffer.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .errors import InvalidOperationError
|
|
7
|
+
from .history import EditHistory, EditRecord
|
|
8
|
+
from .operations import EditOperation
|
|
9
|
+
from .resolver import ResolvedSelection, SelectionResolver
|
|
10
|
+
from .selection import Selection
|
|
11
|
+
|
|
12
|
+
Validator = Callable[[str], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EditBuffer:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
content: str = "",
|
|
19
|
+
*,
|
|
20
|
+
validators: tuple[Validator, ...] = (),
|
|
21
|
+
) -> None:
|
|
22
|
+
self._content = content
|
|
23
|
+
self._version = 0
|
|
24
|
+
self._committed = False
|
|
25
|
+
self._validators = validators
|
|
26
|
+
self._resolver = SelectionResolver()
|
|
27
|
+
self._snapshots = {0: content}
|
|
28
|
+
self.history = EditHistory()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def version(self) -> int:
|
|
32
|
+
return self._version
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def committed(self) -> bool:
|
|
36
|
+
return self._committed
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def versions(self) -> tuple[int, ...]:
|
|
40
|
+
return tuple(self._snapshots)
|
|
41
|
+
|
|
42
|
+
def view(self) -> str:
|
|
43
|
+
return self._content
|
|
44
|
+
|
|
45
|
+
def append(self, text: str) -> EditRecord:
|
|
46
|
+
return self._apply(EditOperation("append", text=text))
|
|
47
|
+
|
|
48
|
+
def replace(
|
|
49
|
+
self,
|
|
50
|
+
target: Selection | Mapping[str, Any],
|
|
51
|
+
text: str,
|
|
52
|
+
) -> EditRecord:
|
|
53
|
+
return self._apply(EditOperation("replace", self._selection(target), text))
|
|
54
|
+
|
|
55
|
+
def insert_before(
|
|
56
|
+
self,
|
|
57
|
+
target: Selection | Mapping[str, Any],
|
|
58
|
+
text: str,
|
|
59
|
+
) -> EditRecord:
|
|
60
|
+
return self._apply(
|
|
61
|
+
EditOperation("insert_before", self._selection(target), text)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def insert_after(
|
|
65
|
+
self,
|
|
66
|
+
target: Selection | Mapping[str, Any],
|
|
67
|
+
text: str,
|
|
68
|
+
) -> EditRecord:
|
|
69
|
+
return self._apply(
|
|
70
|
+
EditOperation("insert_after", self._selection(target), text)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def delete(self, target: Selection | Mapping[str, Any]) -> EditRecord:
|
|
74
|
+
return self._apply(EditOperation("delete", self._selection(target)))
|
|
75
|
+
|
|
76
|
+
def apply(
|
|
77
|
+
self,
|
|
78
|
+
operation: Mapping[str, Any] | EditOperation,
|
|
79
|
+
) -> EditRecord:
|
|
80
|
+
return self._apply(
|
|
81
|
+
operation
|
|
82
|
+
if isinstance(operation, EditOperation)
|
|
83
|
+
else EditOperation.from_dict(operation)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def commit(self) -> str:
|
|
87
|
+
self._ensure_open()
|
|
88
|
+
self._committed = True
|
|
89
|
+
return self._content
|
|
90
|
+
|
|
91
|
+
def rollback(self, version: int) -> EditRecord:
|
|
92
|
+
return self._apply(EditOperation("rollback", version=version))
|
|
93
|
+
|
|
94
|
+
def _apply(self, operation: EditOperation) -> EditRecord:
|
|
95
|
+
self._ensure_open()
|
|
96
|
+
if operation.kind == "rollback":
|
|
97
|
+
return self._rollback(operation)
|
|
98
|
+
resolved = self._resolve(operation)
|
|
99
|
+
before = self._content[resolved.start : resolved.end]
|
|
100
|
+
after = _replacement(operation, before)
|
|
101
|
+
candidate = (
|
|
102
|
+
self._content[: resolved.start]
|
|
103
|
+
+ after
|
|
104
|
+
+ self._content[resolved.end :]
|
|
105
|
+
)
|
|
106
|
+
for validator in self._validators:
|
|
107
|
+
validator(candidate)
|
|
108
|
+
|
|
109
|
+
version_before = self._version
|
|
110
|
+
self._content = candidate
|
|
111
|
+
self._version += 1
|
|
112
|
+
self._snapshots[self._version] = candidate
|
|
113
|
+
record = EditRecord(
|
|
114
|
+
operation=operation,
|
|
115
|
+
start=resolved.start,
|
|
116
|
+
end=resolved.end,
|
|
117
|
+
before=before,
|
|
118
|
+
after=after,
|
|
119
|
+
version_before=version_before,
|
|
120
|
+
version_after=self._version,
|
|
121
|
+
confidence=resolved.confidence,
|
|
122
|
+
)
|
|
123
|
+
self.history.append(record)
|
|
124
|
+
return record
|
|
125
|
+
|
|
126
|
+
def _rollback(self, operation: EditOperation) -> EditRecord:
|
|
127
|
+
if operation.version not in self._snapshots:
|
|
128
|
+
raise InvalidOperationError(f"unknown version: {operation.version}")
|
|
129
|
+
candidate = self._snapshots[operation.version]
|
|
130
|
+
for validator in self._validators:
|
|
131
|
+
validator(candidate)
|
|
132
|
+
before = self._content
|
|
133
|
+
version_before = self._version
|
|
134
|
+
self._content = candidate
|
|
135
|
+
self._version += 1
|
|
136
|
+
self._snapshots[self._version] = candidate
|
|
137
|
+
record = EditRecord(
|
|
138
|
+
operation=operation,
|
|
139
|
+
start=0,
|
|
140
|
+
end=len(before),
|
|
141
|
+
before=before,
|
|
142
|
+
after=candidate,
|
|
143
|
+
version_before=version_before,
|
|
144
|
+
version_after=self._version,
|
|
145
|
+
)
|
|
146
|
+
self.history.append(record)
|
|
147
|
+
return record
|
|
148
|
+
|
|
149
|
+
def _resolve(self, operation: EditOperation) -> ResolvedSelection:
|
|
150
|
+
if operation.kind == "append":
|
|
151
|
+
end = len(self._content)
|
|
152
|
+
return ResolvedSelection(end, end)
|
|
153
|
+
if operation.target is None:
|
|
154
|
+
raise InvalidOperationError(f"{operation.kind} requires a target")
|
|
155
|
+
return self._resolver.resolve(
|
|
156
|
+
self._content,
|
|
157
|
+
operation.target,
|
|
158
|
+
version=self._version,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _selection(self, value: Selection | Mapping[str, Any]) -> Selection:
|
|
162
|
+
return value if isinstance(value, Selection) else Selection.from_dict(value)
|
|
163
|
+
|
|
164
|
+
def _ensure_open(self) -> None:
|
|
165
|
+
if self._committed:
|
|
166
|
+
raise InvalidOperationError("buffer is already committed")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _replacement(operation: EditOperation, before: str) -> str:
|
|
170
|
+
if operation.kind == "append" or operation.kind == "replace":
|
|
171
|
+
return operation.text
|
|
172
|
+
if operation.kind == "insert_before":
|
|
173
|
+
return operation.text + before
|
|
174
|
+
if operation.kind == "insert_after":
|
|
175
|
+
return before + operation.text
|
|
176
|
+
if operation.kind == "delete":
|
|
177
|
+
return ""
|
|
178
|
+
raise InvalidOperationError(f"unknown operation: {operation.kind!r}")
|
editbuffer/cli.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .buffer import EditBuffer
|
|
11
|
+
from .errors import EditBufferError, InvalidOperationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
parser = _parser()
|
|
16
|
+
args = parser.parse_args(argv)
|
|
17
|
+
state_path = Path(args.state)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
if args.command == "new":
|
|
21
|
+
if state_path.exists():
|
|
22
|
+
raise InvalidOperationError(f"state already exists: {state_path}")
|
|
23
|
+
_save(state_path, {"initial": args.text, "operations": [], "committed": False})
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
state = _load(state_path)
|
|
27
|
+
buffer = _restore(state)
|
|
28
|
+
if args.command == "view":
|
|
29
|
+
print(buffer.view())
|
|
30
|
+
elif args.command == "history":
|
|
31
|
+
print(json.dumps(state["operations"], indent=2, ensure_ascii=False))
|
|
32
|
+
elif args.command == "commit":
|
|
33
|
+
result = buffer.commit()
|
|
34
|
+
state["committed"] = True
|
|
35
|
+
_save(state_path, state)
|
|
36
|
+
print(result)
|
|
37
|
+
elif args.command == "rollback":
|
|
38
|
+
operation = {"op": "rollback", "version": args.version}
|
|
39
|
+
buffer.apply(operation)
|
|
40
|
+
state["operations"].append(operation)
|
|
41
|
+
_save(state_path, state)
|
|
42
|
+
else:
|
|
43
|
+
operation = _operation(args)
|
|
44
|
+
buffer.apply(operation)
|
|
45
|
+
state["operations"].append(operation)
|
|
46
|
+
_save(state_path, state)
|
|
47
|
+
return 0
|
|
48
|
+
except (EditBufferError, OSError, ValueError, json.JSONDecodeError) as error:
|
|
49
|
+
parser.exit(2, f"editbuffer: {error}\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parser() -> argparse.ArgumentParser:
|
|
53
|
+
parser = argparse.ArgumentParser(prog="editbuffer")
|
|
54
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
new = subparsers.add_parser("new")
|
|
57
|
+
new.add_argument("state")
|
|
58
|
+
new.add_argument("--text", default="")
|
|
59
|
+
|
|
60
|
+
append = subparsers.add_parser("append")
|
|
61
|
+
append.add_argument("state")
|
|
62
|
+
append.add_argument("text")
|
|
63
|
+
|
|
64
|
+
replace = subparsers.add_parser("replace")
|
|
65
|
+
replace.add_argument("state")
|
|
66
|
+
replace.add_argument("target", help="selection as JSON")
|
|
67
|
+
replace.add_argument("text")
|
|
68
|
+
|
|
69
|
+
apply = subparsers.add_parser("apply")
|
|
70
|
+
apply.add_argument("state")
|
|
71
|
+
apply.add_argument("operation", help="operation as JSON")
|
|
72
|
+
|
|
73
|
+
rollback = subparsers.add_parser("rollback")
|
|
74
|
+
rollback.add_argument("state")
|
|
75
|
+
rollback.add_argument("version", type=int)
|
|
76
|
+
|
|
77
|
+
for name in ("view", "history", "commit"):
|
|
78
|
+
command = subparsers.add_parser(name)
|
|
79
|
+
command.add_argument("state")
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _operation(args: argparse.Namespace) -> dict[str, Any]:
|
|
84
|
+
if args.command == "append":
|
|
85
|
+
return {"op": "append", "text": args.text}
|
|
86
|
+
if args.command == "replace":
|
|
87
|
+
target = json.loads(args.target)
|
|
88
|
+
if not isinstance(target, dict):
|
|
89
|
+
raise InvalidOperationError("target must be a JSON object")
|
|
90
|
+
return {"op": "replace", "target": target, "text": args.text}
|
|
91
|
+
operation = json.loads(args.operation)
|
|
92
|
+
if not isinstance(operation, dict):
|
|
93
|
+
raise InvalidOperationError("operation must be a JSON object")
|
|
94
|
+
return operation
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load(path: Path) -> dict[str, Any]:
|
|
98
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
99
|
+
if not isinstance(state, dict):
|
|
100
|
+
raise InvalidOperationError("state must be a JSON object")
|
|
101
|
+
return state
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _restore(state: dict[str, Any]) -> EditBuffer:
|
|
105
|
+
initial = state.get("initial")
|
|
106
|
+
operations = state.get("operations")
|
|
107
|
+
if not isinstance(initial, str) or not isinstance(operations, list):
|
|
108
|
+
raise InvalidOperationError("invalid state file")
|
|
109
|
+
buffer = EditBuffer(initial)
|
|
110
|
+
for operation in operations:
|
|
111
|
+
if not isinstance(operation, dict):
|
|
112
|
+
raise InvalidOperationError("invalid operation in state file")
|
|
113
|
+
buffer.apply(operation)
|
|
114
|
+
if state.get("committed"):
|
|
115
|
+
buffer.commit()
|
|
116
|
+
return buffer
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _save(path: Path, state: dict[str, Any]) -> None:
|
|
120
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
handle = tempfile.NamedTemporaryFile(
|
|
122
|
+
"w",
|
|
123
|
+
encoding="utf-8",
|
|
124
|
+
dir=path.parent,
|
|
125
|
+
delete=False,
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
with handle:
|
|
129
|
+
json.dump(state, handle, indent=2, ensure_ascii=False)
|
|
130
|
+
handle.write("\n")
|
|
131
|
+
os.replace(handle.name, path)
|
|
132
|
+
except BaseException:
|
|
133
|
+
Path(handle.name).unlink(missing_ok=True)
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
raise SystemExit(main())
|
editbuffer/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class EditBufferError(Exception):
|
|
2
|
+
"""Base error for editbuffer."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TargetNotFoundError(EditBufferError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AmbiguousTargetError(EditBufferError):
|
|
10
|
+
def __init__(self, candidates: tuple[tuple[int, int], ...]) -> None:
|
|
11
|
+
self.candidates = candidates
|
|
12
|
+
super().__init__(f"selection matched {len(candidates)} targets: {candidates}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FuzzyMatchError(EditBufferError):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
reason: str,
|
|
19
|
+
candidates: tuple[tuple[int, int, float], ...],
|
|
20
|
+
) -> None:
|
|
21
|
+
self.reason = reason
|
|
22
|
+
self.candidates = candidates
|
|
23
|
+
super().__init__(f"fuzzy selection {reason}: {candidates}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InvalidOperationError(EditBufferError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StaleVersionError(EditBufferError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationError(EditBufferError):
|
|
35
|
+
pass
|
editbuffer/history.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
|
|
4
|
+
from .operations import EditOperation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class EditRecord:
|
|
9
|
+
operation: EditOperation
|
|
10
|
+
start: int
|
|
11
|
+
end: int
|
|
12
|
+
before: str
|
|
13
|
+
after: str
|
|
14
|
+
version_before: int
|
|
15
|
+
version_after: int
|
|
16
|
+
confidence: float = 1.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EditHistory:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._records: list[EditRecord] = []
|
|
22
|
+
|
|
23
|
+
def append(self, record: EditRecord) -> None:
|
|
24
|
+
self._records.append(record)
|
|
25
|
+
|
|
26
|
+
def __len__(self) -> int:
|
|
27
|
+
return len(self._records)
|
|
28
|
+
|
|
29
|
+
def __getitem__(self, index: int) -> EditRecord:
|
|
30
|
+
return self._records[index]
|
|
31
|
+
|
|
32
|
+
def __iter__(self) -> Iterator[EditRecord]:
|
|
33
|
+
return iter(self._records)
|
editbuffer/mcp_server.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from .buffer import EditBuffer
|
|
8
|
+
from .history import EditRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BufferRegistry:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._buffers: dict[str, EditBuffer] = {}
|
|
14
|
+
self._commands: list[dict[str, Any]] = []
|
|
15
|
+
self._next_command_number = 1
|
|
16
|
+
|
|
17
|
+
def create(
|
|
18
|
+
self,
|
|
19
|
+
content: str = "",
|
|
20
|
+
*,
|
|
21
|
+
buffer_id: str | None = None,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
identifier = buffer_id or uuid4().hex
|
|
24
|
+
if identifier in self._buffers:
|
|
25
|
+
raise ValueError(f"buffer already exists: {identifier}")
|
|
26
|
+
self._buffers[identifier] = EditBuffer(content)
|
|
27
|
+
return self.view(identifier)
|
|
28
|
+
|
|
29
|
+
def list_buffers(self) -> list[dict[str, Any]]:
|
|
30
|
+
return [
|
|
31
|
+
self._state(identifier, buffer)
|
|
32
|
+
for identifier, buffer in self._buffers.items()
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def view(self, buffer_id: str) -> dict[str, Any]:
|
|
36
|
+
return self._state(buffer_id, self._get(buffer_id))
|
|
37
|
+
|
|
38
|
+
def edit(self, buffer_id: str, operation: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
buffer = self._get(buffer_id)
|
|
40
|
+
record = buffer.apply(operation)
|
|
41
|
+
result = self._state(buffer_id, buffer)
|
|
42
|
+
result["applied"] = _record(record)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
def history(self, buffer_id: str) -> list[dict[str, Any]]:
|
|
46
|
+
return [_record(record) for record in self._get(buffer_id).history]
|
|
47
|
+
|
|
48
|
+
def rollback(self, buffer_id: str, version: int) -> dict[str, Any]:
|
|
49
|
+
buffer = self._get(buffer_id)
|
|
50
|
+
record = buffer.rollback(version)
|
|
51
|
+
result = self._state(buffer_id, buffer)
|
|
52
|
+
result["applied"] = _record(record)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
def commit(self, buffer_id: str) -> dict[str, Any]:
|
|
56
|
+
buffer = self._get(buffer_id)
|
|
57
|
+
buffer.commit()
|
|
58
|
+
self._remember_command(buffer.view())
|
|
59
|
+
return self._state(buffer_id, buffer)
|
|
60
|
+
|
|
61
|
+
def command_history(self) -> list[dict[str, Any]]:
|
|
62
|
+
return list(self._commands)
|
|
63
|
+
|
|
64
|
+
def select_command(
|
|
65
|
+
self,
|
|
66
|
+
command_id: str,
|
|
67
|
+
*,
|
|
68
|
+
buffer_id: str | None = None,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
for item in self._commands:
|
|
71
|
+
if item["command_id"] == command_id:
|
|
72
|
+
return self.create(item["command"], buffer_id=buffer_id)
|
|
73
|
+
raise KeyError(f"unknown command: {command_id}")
|
|
74
|
+
|
|
75
|
+
def _get(self, buffer_id: str) -> EditBuffer:
|
|
76
|
+
try:
|
|
77
|
+
return self._buffers[buffer_id]
|
|
78
|
+
except KeyError as error:
|
|
79
|
+
raise KeyError(f"unknown buffer: {buffer_id}") from error
|
|
80
|
+
|
|
81
|
+
def _state(self, buffer_id: str, buffer: EditBuffer) -> dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"buffer_id": buffer_id,
|
|
84
|
+
"content": buffer.view(),
|
|
85
|
+
"version": buffer.version,
|
|
86
|
+
"versions": list(buffer.versions),
|
|
87
|
+
"committed": buffer.committed,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def _remember_command(self, command: str) -> None:
|
|
91
|
+
if not command.strip():
|
|
92
|
+
return
|
|
93
|
+
self._commands.insert(
|
|
94
|
+
0,
|
|
95
|
+
{
|
|
96
|
+
"command_id": f"cmd-{self._next_command_number}",
|
|
97
|
+
"command": command,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
self._next_command_number += 1
|
|
101
|
+
del self._commands[10:]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _record(record: EditRecord) -> dict[str, Any]:
|
|
105
|
+
return {
|
|
106
|
+
"operation": record.operation.as_dict(),
|
|
107
|
+
"start": record.start,
|
|
108
|
+
"end": record.end,
|
|
109
|
+
"before": record.before,
|
|
110
|
+
"after": record.after,
|
|
111
|
+
"version_before": record.version_before,
|
|
112
|
+
"version_after": record.version_after,
|
|
113
|
+
"confidence": record.confidence,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_server() -> Any:
|
|
118
|
+
try:
|
|
119
|
+
FastMCP = import_module("mcp.server.fastmcp").FastMCP
|
|
120
|
+
except ImportError as error:
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
"MCP support is not installed; run: pip install 'editbuffer[mcp]'"
|
|
123
|
+
) from error
|
|
124
|
+
|
|
125
|
+
registry = BufferRegistry()
|
|
126
|
+
server = FastMCP(
|
|
127
|
+
"editbuffer",
|
|
128
|
+
instructions=(
|
|
129
|
+
"Use editbuffer for pending output that may need local corrections before "
|
|
130
|
+
"commit. Create one buffer, apply small selection-based edits, view when "
|
|
131
|
+
"needed, and commit only when final. Never retry ambiguous fuzzy edits "
|
|
132
|
+
"without narrowing the selection."
|
|
133
|
+
),
|
|
134
|
+
json_response=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@server.tool()
|
|
138
|
+
def buffer_create(
|
|
139
|
+
content: str = "",
|
|
140
|
+
buffer_id: str | None = None,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""Create an in-memory pending output buffer."""
|
|
143
|
+
return registry.create(content, buffer_id=buffer_id)
|
|
144
|
+
|
|
145
|
+
@server.tool()
|
|
146
|
+
def buffer_list() -> list[dict[str, Any]]:
|
|
147
|
+
"""List active pending output buffers."""
|
|
148
|
+
return registry.list_buffers()
|
|
149
|
+
|
|
150
|
+
@server.tool()
|
|
151
|
+
def buffer_view(buffer_id: str) -> dict[str, Any]:
|
|
152
|
+
"""View current content, version, snapshots, and commit state."""
|
|
153
|
+
return registry.view(buffer_id)
|
|
154
|
+
|
|
155
|
+
@server.tool()
|
|
156
|
+
def buffer_edit(
|
|
157
|
+
buffer_id: str,
|
|
158
|
+
operation: dict[str, Any],
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Apply one JSON edit operation.
|
|
161
|
+
|
|
162
|
+
Example replace operation:
|
|
163
|
+
{"op": "replace", "target": {"type": "exact", "text": "old"}, "text": "new"}
|
|
164
|
+
Operations: append, replace, insert_before, insert_after, delete, rollback.
|
|
165
|
+
Targets: exact/context/range/fuzzy/block. Ambiguous edits fail without mutation.
|
|
166
|
+
"""
|
|
167
|
+
return registry.edit(buffer_id, operation)
|
|
168
|
+
|
|
169
|
+
@server.tool()
|
|
170
|
+
def buffer_history(buffer_id: str) -> list[dict[str, Any]]:
|
|
171
|
+
"""Return the audit trail for successful edits."""
|
|
172
|
+
return registry.history(buffer_id)
|
|
173
|
+
|
|
174
|
+
@server.tool()
|
|
175
|
+
def buffer_rollback(buffer_id: str, version: int) -> dict[str, Any]:
|
|
176
|
+
"""Restore a prior snapshot as a new audited version."""
|
|
177
|
+
return registry.rollback(buffer_id, version)
|
|
178
|
+
|
|
179
|
+
@server.tool()
|
|
180
|
+
def buffer_commit(buffer_id: str) -> dict[str, Any]:
|
|
181
|
+
"""Commit final output, close the buffer, and remember it as a reusable command."""
|
|
182
|
+
return registry.commit(buffer_id)
|
|
183
|
+
|
|
184
|
+
@server.tool()
|
|
185
|
+
def command_history() -> list[dict[str, Any]]:
|
|
186
|
+
"""Return up to 10 most recently committed commands, newest first."""
|
|
187
|
+
return registry.command_history()
|
|
188
|
+
|
|
189
|
+
@server.tool()
|
|
190
|
+
def command_select(
|
|
191
|
+
command_id: str,
|
|
192
|
+
buffer_id: str | None = None,
|
|
193
|
+
) -> dict[str, Any]:
|
|
194
|
+
"""Create a new pending buffer from a previous command instead of regenerating it."""
|
|
195
|
+
return registry.select_command(command_id, buffer_id=buffer_id)
|
|
196
|
+
|
|
197
|
+
return server
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main() -> None:
|
|
201
|
+
create_server().run(transport="stdio")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|