editbuffer 0.2.1__tar.gz → 0.2.3__tar.gz
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-0.2.1/src/editbuffer.egg-info → editbuffer-0.2.3}/PKG-INFO +26 -10
- {editbuffer-0.2.1 → editbuffer-0.2.3}/README.md +25 -9
- {editbuffer-0.2.1 → editbuffer-0.2.3}/pyproject.toml +1 -1
- editbuffer-0.2.3/src/editbuffer/history.py +258 -0
- editbuffer-0.2.3/src/editbuffer/mcp_server.py +454 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3/src/editbuffer.egg-info}/PKG-INFO +26 -10
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/SOURCES.txt +5 -1
- editbuffer-0.2.3/tests/test_collect_harbor_results.py +40 -0
- editbuffer-0.2.3/tests/test_mcp_server.py +154 -0
- editbuffer-0.2.3/tests/test_mcp_stdio_eval.py +384 -0
- editbuffer-0.2.3/tests/test_metrics.py +34 -0
- editbuffer-0.2.3/tests/test_parse_agent_trajectories.py +123 -0
- editbuffer-0.2.3/tests/test_run_terminal_bench_ab.py +124 -0
- editbuffer-0.2.1/src/editbuffer/history.py +0 -33
- editbuffer-0.2.1/src/editbuffer/mcp_server.py +0 -205
- editbuffer-0.2.1/tests/test_mcp_server.py +0 -81
- editbuffer-0.2.1/tests/test_mcp_stdio_eval.py +0 -292
- {editbuffer-0.2.1 → editbuffer-0.2.3}/LICENSE +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/setup.cfg +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/__init__.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/blocks.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/buffer.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/cli.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/errors.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/operations.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/py.typed +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/resolver.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/selection.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/validators.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/dependency_links.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/entry_points.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/requires.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/top_level.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/tests/test_cli.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.3}/tests/test_editbuffer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: editbuffer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Selection-based mutable output buffer for LLM tools
|
|
5
5
|
Author: averagedigital
|
|
6
6
|
License-Expression: MIT
|
|
@@ -199,10 +199,10 @@ Install the optional server:
|
|
|
199
199
|
pipx install 'editbuffer[mcp]'
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
Or install with `uvx` without a persistent environment:
|
|
203
203
|
|
|
204
204
|
```bash
|
|
205
|
-
|
|
205
|
+
uvx --from 'editbuffer[mcp]' editbuffer-mcp
|
|
206
206
|
```
|
|
207
207
|
|
|
208
208
|
Connect it to Codex:
|
|
@@ -219,23 +219,39 @@ Claude Desktop and generic MCP client examples are in
|
|
|
219
219
|
|
|
220
220
|
The server exposes:
|
|
221
221
|
|
|
222
|
-
- `
|
|
222
|
+
- `buffer_append`
|
|
223
223
|
- `buffer_list`
|
|
224
224
|
- `buffer_view`
|
|
225
225
|
- `buffer_edit`
|
|
226
|
+
- `buffer_replace`
|
|
227
|
+
- `buffer_insert_before`
|
|
228
|
+
- `buffer_insert_after`
|
|
229
|
+
- `buffer_delete`
|
|
226
230
|
- `buffer_history`
|
|
227
231
|
- `buffer_rollback`
|
|
228
232
|
- `buffer_commit`
|
|
229
|
-
- `
|
|
230
|
-
- `
|
|
233
|
+
- `tool_history`
|
|
234
|
+
- `tool_select`
|
|
231
235
|
|
|
232
236
|
Buffers are in-memory and live for the MCP server process. The MCP layer calls
|
|
233
237
|
the same core API and does not implement separate edit semantics.
|
|
234
238
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
+
Use the first-class selection tools for normal agent use:
|
|
240
|
+
|
|
241
|
+
```json
|
|
242
|
+
{
|
|
243
|
+
"buffer_id": "answer",
|
|
244
|
+
"target": {"type": "exact", "text": "old"},
|
|
245
|
+
"text": "new"
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`buffer_edit` remains available for raw JSON operations.
|
|
250
|
+
|
|
251
|
+
MCP calls are recorded in SQLite-backed history. `tool_history` returns recent
|
|
252
|
+
calls, newest first. `tool_select` creates a pending buffer from selectable
|
|
253
|
+
content in a previous call so the model can repair it instead of regenerating
|
|
254
|
+
it.
|
|
239
255
|
|
|
240
256
|
## Examples
|
|
241
257
|
|
|
@@ -181,10 +181,10 @@ Install the optional server:
|
|
|
181
181
|
pipx install 'editbuffer[mcp]'
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
Or install with `uvx` without a persistent environment:
|
|
185
185
|
|
|
186
186
|
```bash
|
|
187
|
-
|
|
187
|
+
uvx --from 'editbuffer[mcp]' editbuffer-mcp
|
|
188
188
|
```
|
|
189
189
|
|
|
190
190
|
Connect it to Codex:
|
|
@@ -201,23 +201,39 @@ Claude Desktop and generic MCP client examples are in
|
|
|
201
201
|
|
|
202
202
|
The server exposes:
|
|
203
203
|
|
|
204
|
-
- `
|
|
204
|
+
- `buffer_append`
|
|
205
205
|
- `buffer_list`
|
|
206
206
|
- `buffer_view`
|
|
207
207
|
- `buffer_edit`
|
|
208
|
+
- `buffer_replace`
|
|
209
|
+
- `buffer_insert_before`
|
|
210
|
+
- `buffer_insert_after`
|
|
211
|
+
- `buffer_delete`
|
|
208
212
|
- `buffer_history`
|
|
209
213
|
- `buffer_rollback`
|
|
210
214
|
- `buffer_commit`
|
|
211
|
-
- `
|
|
212
|
-
- `
|
|
215
|
+
- `tool_history`
|
|
216
|
+
- `tool_select`
|
|
213
217
|
|
|
214
218
|
Buffers are in-memory and live for the MCP server process. The MCP layer calls
|
|
215
219
|
the same core API and does not implement separate edit semantics.
|
|
216
220
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
Use the first-class selection tools for normal agent use:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"buffer_id": "answer",
|
|
226
|
+
"target": {"type": "exact", "text": "old"},
|
|
227
|
+
"text": "new"
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`buffer_edit` remains available for raw JSON operations.
|
|
232
|
+
|
|
233
|
+
MCP calls are recorded in SQLite-backed history. `tool_history` returns recent
|
|
234
|
+
calls, newest first. `tool_select` creates a pending buffer from selectable
|
|
235
|
+
content in a previous call so the model can repair it instead of regenerating
|
|
236
|
+
it.
|
|
221
237
|
|
|
222
238
|
## Examples
|
|
223
239
|
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from .operations import EditOperation
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class EditRecord:
|
|
18
|
+
operation: EditOperation
|
|
19
|
+
start: int
|
|
20
|
+
end: int
|
|
21
|
+
before: str
|
|
22
|
+
after: str
|
|
23
|
+
version_before: int
|
|
24
|
+
version_after: int
|
|
25
|
+
confidence: float = 1.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EditHistory:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._records: list[EditRecord] = []
|
|
31
|
+
|
|
32
|
+
def append(self, record: EditRecord) -> None:
|
|
33
|
+
self._records.append(record)
|
|
34
|
+
|
|
35
|
+
def __len__(self) -> int:
|
|
36
|
+
return len(self._records)
|
|
37
|
+
|
|
38
|
+
def __getitem__(self, index: int) -> EditRecord:
|
|
39
|
+
return self._records[index]
|
|
40
|
+
|
|
41
|
+
def __iter__(self) -> Iterator[EditRecord]:
|
|
42
|
+
return iter(self._records)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
SECRET_KEYS = ("api_key", "token", "secret", "password", "authorization")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ToolHistoryStore:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
path: str | Path | None = None,
|
|
52
|
+
*,
|
|
53
|
+
retention_days: int | None = None,
|
|
54
|
+
default_limit: int | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.path = Path(path) if path is not None else _default_history_path()
|
|
57
|
+
self.retention_days = (
|
|
58
|
+
retention_days
|
|
59
|
+
if retention_days is not None
|
|
60
|
+
else int(os.environ.get("EDITBUFFER_HISTORY_RETENTION_DAYS", "7"))
|
|
61
|
+
)
|
|
62
|
+
self.default_limit = (
|
|
63
|
+
default_limit
|
|
64
|
+
if default_limit is not None
|
|
65
|
+
else int(os.environ.get("EDITBUFFER_HISTORY_LIMIT", "10"))
|
|
66
|
+
)
|
|
67
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
self._init_db()
|
|
69
|
+
self.cleanup()
|
|
70
|
+
|
|
71
|
+
def record_tool_call(
|
|
72
|
+
self,
|
|
73
|
+
tool_name: str,
|
|
74
|
+
arguments: dict[str, Any] | None = None,
|
|
75
|
+
*,
|
|
76
|
+
call_id: str | None = None,
|
|
77
|
+
result: Any = None,
|
|
78
|
+
status: str = "success",
|
|
79
|
+
error: str | None = None,
|
|
80
|
+
content: str | None = None,
|
|
81
|
+
command: str | None = None,
|
|
82
|
+
timestamp: datetime | None = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
self.cleanup()
|
|
85
|
+
identifier = call_id or f"call-{uuid4().hex}"
|
|
86
|
+
when = timestamp or datetime.now(UTC)
|
|
87
|
+
redacted_arguments = _redact(arguments or {})
|
|
88
|
+
redacted_result = _redact(result)
|
|
89
|
+
with self._connect() as db:
|
|
90
|
+
db.execute(
|
|
91
|
+
"""
|
|
92
|
+
INSERT OR REPLACE INTO tool_calls (
|
|
93
|
+
call_id, timestamp, tool_name, arguments_json, result_json,
|
|
94
|
+
result_summary, status, error, content, command
|
|
95
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
96
|
+
""",
|
|
97
|
+
(
|
|
98
|
+
identifier,
|
|
99
|
+
when.isoformat(),
|
|
100
|
+
tool_name,
|
|
101
|
+
_json_dump(redacted_arguments),
|
|
102
|
+
_json_dump(redacted_result),
|
|
103
|
+
_summary(redacted_result),
|
|
104
|
+
status,
|
|
105
|
+
error,
|
|
106
|
+
content,
|
|
107
|
+
command,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
return identifier
|
|
111
|
+
|
|
112
|
+
def list_tool_calls(self, limit: int | None = None) -> list[dict[str, Any]]:
|
|
113
|
+
self.cleanup()
|
|
114
|
+
row_limit = self.default_limit if limit is None else limit
|
|
115
|
+
with self._connect() as db:
|
|
116
|
+
rows = db.execute(
|
|
117
|
+
"""
|
|
118
|
+
SELECT call_id, timestamp, tool_name, arguments_json, result_json,
|
|
119
|
+
result_summary, status, error, content, command
|
|
120
|
+
FROM tool_calls
|
|
121
|
+
ORDER BY timestamp DESC, rowid DESC
|
|
122
|
+
LIMIT ?
|
|
123
|
+
""",
|
|
124
|
+
(row_limit,),
|
|
125
|
+
).fetchall()
|
|
126
|
+
return [_row(row) for row in rows]
|
|
127
|
+
|
|
128
|
+
def get_tool_call(self, call_id: str) -> dict[str, Any]:
|
|
129
|
+
self.cleanup()
|
|
130
|
+
with self._connect() as db:
|
|
131
|
+
row = db.execute(
|
|
132
|
+
"""
|
|
133
|
+
SELECT call_id, timestamp, tool_name, arguments_json, result_json,
|
|
134
|
+
result_summary, status, error, content, command
|
|
135
|
+
FROM tool_calls
|
|
136
|
+
WHERE call_id = ?
|
|
137
|
+
""",
|
|
138
|
+
(call_id,),
|
|
139
|
+
).fetchone()
|
|
140
|
+
if row is None:
|
|
141
|
+
raise KeyError(f"unknown tool call: {call_id}")
|
|
142
|
+
return _row(row)
|
|
143
|
+
|
|
144
|
+
def command_history(self, limit: int | None = None) -> list[dict[str, str]]:
|
|
145
|
+
row_limit = self.default_limit if limit is None else limit
|
|
146
|
+
with self._connect() as db:
|
|
147
|
+
rows = db.execute(
|
|
148
|
+
"""
|
|
149
|
+
SELECT call_id, command
|
|
150
|
+
FROM tool_calls
|
|
151
|
+
WHERE command IS NOT NULL AND command != ''
|
|
152
|
+
ORDER BY timestamp DESC, rowid DESC
|
|
153
|
+
LIMIT ?
|
|
154
|
+
""",
|
|
155
|
+
(row_limit,),
|
|
156
|
+
).fetchall()
|
|
157
|
+
return [{"command_id": row[0], "command": row[1]} for row in rows]
|
|
158
|
+
|
|
159
|
+
def get_command(self, command_id: str) -> str:
|
|
160
|
+
with self._connect() as db:
|
|
161
|
+
row = db.execute(
|
|
162
|
+
"""
|
|
163
|
+
SELECT command
|
|
164
|
+
FROM tool_calls
|
|
165
|
+
WHERE call_id = ? AND command IS NOT NULL AND command != ''
|
|
166
|
+
""",
|
|
167
|
+
(command_id,),
|
|
168
|
+
).fetchone()
|
|
169
|
+
if row is None:
|
|
170
|
+
raise KeyError(f"unknown command: {command_id}")
|
|
171
|
+
return str(row[0])
|
|
172
|
+
|
|
173
|
+
def cleanup(self) -> None:
|
|
174
|
+
cutoff = datetime.now(UTC) - timedelta(days=self.retention_days)
|
|
175
|
+
with self._connect() as db:
|
|
176
|
+
db.execute("DELETE FROM tool_calls WHERE timestamp < ?", (cutoff.isoformat(),))
|
|
177
|
+
|
|
178
|
+
def _init_db(self) -> None:
|
|
179
|
+
with self._connect() as db:
|
|
180
|
+
db.execute(
|
|
181
|
+
"""
|
|
182
|
+
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
183
|
+
call_id TEXT PRIMARY KEY,
|
|
184
|
+
timestamp TEXT NOT NULL,
|
|
185
|
+
tool_name TEXT NOT NULL,
|
|
186
|
+
arguments_json TEXT NOT NULL,
|
|
187
|
+
result_json TEXT,
|
|
188
|
+
result_summary TEXT,
|
|
189
|
+
status TEXT NOT NULL,
|
|
190
|
+
error TEXT,
|
|
191
|
+
content TEXT,
|
|
192
|
+
command TEXT
|
|
193
|
+
)
|
|
194
|
+
"""
|
|
195
|
+
)
|
|
196
|
+
db.execute(
|
|
197
|
+
"CREATE INDEX IF NOT EXISTS idx_tool_calls_timestamp ON tool_calls(timestamp)"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _connect(self) -> sqlite3.Connection:
|
|
201
|
+
return sqlite3.connect(self.path)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _default_history_path() -> Path:
|
|
205
|
+
configured = os.environ.get("EDITBUFFER_HISTORY_DB")
|
|
206
|
+
if configured:
|
|
207
|
+
return Path(configured)
|
|
208
|
+
return Path.home() / ".editbuffer" / "history.sqlite3"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _redact(value: Any) -> Any:
|
|
212
|
+
if isinstance(value, dict):
|
|
213
|
+
redacted: dict[str, Any] = {}
|
|
214
|
+
for key, item in value.items():
|
|
215
|
+
if any(secret in str(key).lower() for secret in SECRET_KEYS):
|
|
216
|
+
redacted[key] = "[REDACTED]"
|
|
217
|
+
else:
|
|
218
|
+
redacted[key] = _redact(item)
|
|
219
|
+
return redacted
|
|
220
|
+
if isinstance(value, list):
|
|
221
|
+
return [_redact(item) for item in value]
|
|
222
|
+
return value
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _json_dump(value: Any) -> str | None:
|
|
226
|
+
if value is None:
|
|
227
|
+
return None
|
|
228
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _json_load(value: str | None) -> Any:
|
|
232
|
+
if value is None:
|
|
233
|
+
return None
|
|
234
|
+
return json.loads(value)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _summary(value: Any) -> str | None:
|
|
238
|
+
if value is None:
|
|
239
|
+
return None
|
|
240
|
+
text = _json_dump(value)
|
|
241
|
+
if text is None:
|
|
242
|
+
return None
|
|
243
|
+
return text[:500]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _row(row: tuple[Any, ...]) -> dict[str, Any]:
|
|
247
|
+
return {
|
|
248
|
+
"call_id": row[0],
|
|
249
|
+
"timestamp": row[1],
|
|
250
|
+
"tool_name": row[2],
|
|
251
|
+
"arguments": _json_load(row[3]),
|
|
252
|
+
"result": _json_load(row[4]),
|
|
253
|
+
"result_summary": row[5],
|
|
254
|
+
"status": row[6],
|
|
255
|
+
"error": row[7],
|
|
256
|
+
"content": row[8],
|
|
257
|
+
"command": row[9],
|
|
258
|
+
}
|