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.
Files changed (35) hide show
  1. {editbuffer-0.2.1/src/editbuffer.egg-info → editbuffer-0.2.3}/PKG-INFO +26 -10
  2. {editbuffer-0.2.1 → editbuffer-0.2.3}/README.md +25 -9
  3. {editbuffer-0.2.1 → editbuffer-0.2.3}/pyproject.toml +1 -1
  4. editbuffer-0.2.3/src/editbuffer/history.py +258 -0
  5. editbuffer-0.2.3/src/editbuffer/mcp_server.py +454 -0
  6. {editbuffer-0.2.1 → editbuffer-0.2.3/src/editbuffer.egg-info}/PKG-INFO +26 -10
  7. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/SOURCES.txt +5 -1
  8. editbuffer-0.2.3/tests/test_collect_harbor_results.py +40 -0
  9. editbuffer-0.2.3/tests/test_mcp_server.py +154 -0
  10. editbuffer-0.2.3/tests/test_mcp_stdio_eval.py +384 -0
  11. editbuffer-0.2.3/tests/test_metrics.py +34 -0
  12. editbuffer-0.2.3/tests/test_parse_agent_trajectories.py +123 -0
  13. editbuffer-0.2.3/tests/test_run_terminal_bench_ab.py +124 -0
  14. editbuffer-0.2.1/src/editbuffer/history.py +0 -33
  15. editbuffer-0.2.1/src/editbuffer/mcp_server.py +0 -205
  16. editbuffer-0.2.1/tests/test_mcp_server.py +0 -81
  17. editbuffer-0.2.1/tests/test_mcp_stdio_eval.py +0 -292
  18. {editbuffer-0.2.1 → editbuffer-0.2.3}/LICENSE +0 -0
  19. {editbuffer-0.2.1 → editbuffer-0.2.3}/setup.cfg +0 -0
  20. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/__init__.py +0 -0
  21. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/blocks.py +0 -0
  22. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/buffer.py +0 -0
  23. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/cli.py +0 -0
  24. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/errors.py +0 -0
  25. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/operations.py +0 -0
  26. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/py.typed +0 -0
  27. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/resolver.py +0 -0
  28. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/selection.py +0 -0
  29. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer/validators.py +0 -0
  30. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/dependency_links.txt +0 -0
  31. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/entry_points.txt +0 -0
  32. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/requires.txt +0 -0
  33. {editbuffer-0.2.1 → editbuffer-0.2.3}/src/editbuffer.egg-info/top_level.txt +0 -0
  34. {editbuffer-0.2.1 → editbuffer-0.2.3}/tests/test_cli.py +0 -0
  35. {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.1
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
- Until a PyPI release exists, install directly from GitHub:
202
+ Or install with `uvx` without a persistent environment:
203
203
 
204
204
  ```bash
205
- pipx install 'editbuffer[mcp] @ git+https://github.com/averagedigital/editbuffer.git'
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
- - `buffer_create`
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
- - `command_history`
230
- - `command_select`
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
- `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
+ 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
- Until a PyPI release exists, install directly from GitHub:
184
+ Or install with `uvx` without a persistent environment:
185
185
 
186
186
  ```bash
187
- pipx install 'editbuffer[mcp] @ git+https://github.com/averagedigital/editbuffer.git'
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
- - `buffer_create`
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
- - `command_history`
212
- - `command_select`
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
- `buffer_commit` remembers non-empty committed output as a reusable command.
218
- `command_history` returns the last 10 commands, newest first. `command_select`
219
- creates a new pending buffer from a previous command so the model can reuse it
220
- instead of regenerating it.
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "editbuffer"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Selection-based mutable output buffer for LLM tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
+ }