bareagent-cli 0.1.0__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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Apply an LSP ``WorkspaceEdit`` to disk.
|
|
2
|
+
|
|
3
|
+
A ``WorkspaceEdit`` is what a language server returns from
|
|
4
|
+
``textDocument/rename``. It can carry edits in two shapes (LSP 3.x):
|
|
5
|
+
|
|
6
|
+
* ``changes`` — ``{uri: [TextEdit, ...]}`` (the legacy map form).
|
|
7
|
+
* ``documentChanges`` — an ordered list whose items are either
|
|
8
|
+
``TextDocumentEdit`` (``{"textDocument": {"uri": ...}, "edits": [...]}``) or
|
|
9
|
+
*resource operations* (``CreateFile`` / ``RenameFile`` / ``DeleteFile``,
|
|
10
|
+
distinguished by a ``"kind"`` field). The semantic-rename MVP does **not**
|
|
11
|
+
perform file-level operations, so resource operations are collected into a
|
|
12
|
+
``skipped`` list and surfaced to the caller rather than applied.
|
|
13
|
+
|
|
14
|
+
This module is intentionally free of any multilspy / LSP-client dependency so
|
|
15
|
+
it can be unit-tested with plain dicts. It only reads the file from disk,
|
|
16
|
+
applies ``TextEdit`` ranges, and writes back via
|
|
17
|
+
:func:`bareagent.core.fileutil.atomic_write_text`.
|
|
18
|
+
|
|
19
|
+
Coordinates inside a ``TextEdit`` ``range`` are 0-based ``(line, character)``
|
|
20
|
+
in LSP wire form. Multiple edits to the same file are applied **bottom-up**
|
|
21
|
+
(sorted by start position descending) so earlier edits never shift the
|
|
22
|
+
character offsets of later ones.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from bareagent.core.fileutil import atomic_write_text
|
|
31
|
+
|
|
32
|
+
from .coord import document_uri_to_path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class WorkspaceEditResult:
|
|
37
|
+
"""Outcome of applying a ``WorkspaceEdit``.
|
|
38
|
+
|
|
39
|
+
``files`` maps an absolute (native) file path to the number of ``TextEdit``
|
|
40
|
+
entries applied to it. ``skipped`` holds human-readable descriptions of any
|
|
41
|
+
resource operations (CreateFile / RenameFile / DeleteFile) that the MVP did
|
|
42
|
+
not perform.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
files: dict[str, int] = field(default_factory=dict)
|
|
46
|
+
skipped: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def total_edits(self) -> int:
|
|
50
|
+
return sum(self.files.values())
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def changed_any(self) -> bool:
|
|
54
|
+
return bool(self.files)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _iter_edit_groups(
|
|
58
|
+
workspace_edit: dict[str, Any],
|
|
59
|
+
skipped: list[str],
|
|
60
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
61
|
+
"""Normalize a WorkspaceEdit into ``{uri: [TextEdit, ...]}``.
|
|
62
|
+
|
|
63
|
+
A ``WorkspaceEdit`` may carry edits in ``changes`` *and/or*
|
|
64
|
+
``documentChanges``. The LSP spec recommends a client that understands
|
|
65
|
+
``documentChanges`` use it **exclusively** when present and ignore
|
|
66
|
+
``changes`` entirely — ``changes`` is only the backward-compatibility
|
|
67
|
+
fallback for clients that don't. Merging both would apply the same edit
|
|
68
|
+
twice when a server emits both forms for one URI, and the bottom-up splice
|
|
69
|
+
would then corrupt the file. So we choose one form, never merge.
|
|
70
|
+
|
|
71
|
+
Resource operations inside ``documentChanges`` (items carrying a ``"kind"``
|
|
72
|
+
field) are recorded in ``skipped`` and not returned for application.
|
|
73
|
+
"""
|
|
74
|
+
groups: dict[str, list[dict[str, Any]]] = {}
|
|
75
|
+
|
|
76
|
+
document_changes = workspace_edit.get("documentChanges")
|
|
77
|
+
if isinstance(document_changes, list):
|
|
78
|
+
# documentChanges present: parse it and ignore ``changes`` entirely.
|
|
79
|
+
for item in document_changes:
|
|
80
|
+
if not isinstance(item, dict):
|
|
81
|
+
continue
|
|
82
|
+
kind = item.get("kind")
|
|
83
|
+
if kind in ("create", "rename", "delete"):
|
|
84
|
+
# Resource operation — MVP does not do file-level renames.
|
|
85
|
+
skipped.append(_describe_resource_op(kind, item))
|
|
86
|
+
continue
|
|
87
|
+
text_document = item.get("textDocument")
|
|
88
|
+
uri = (
|
|
89
|
+
text_document.get("uri")
|
|
90
|
+
if isinstance(text_document, dict)
|
|
91
|
+
else None
|
|
92
|
+
)
|
|
93
|
+
edits = item.get("edits")
|
|
94
|
+
if not isinstance(uri, str) or not isinstance(edits, list):
|
|
95
|
+
continue
|
|
96
|
+
groups.setdefault(uri, []).extend(
|
|
97
|
+
edit for edit in edits if isinstance(edit, dict)
|
|
98
|
+
)
|
|
99
|
+
return groups
|
|
100
|
+
|
|
101
|
+
changes = workspace_edit.get("changes")
|
|
102
|
+
if isinstance(changes, dict):
|
|
103
|
+
for uri, edits in changes.items():
|
|
104
|
+
if not isinstance(uri, str) or not isinstance(edits, list):
|
|
105
|
+
continue
|
|
106
|
+
groups.setdefault(uri, []).extend(
|
|
107
|
+
edit for edit in edits if isinstance(edit, dict)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return groups
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _describe_resource_op(kind: str, item: dict[str, Any]) -> str:
|
|
114
|
+
"""Best-effort one-line description of a skipped resource operation."""
|
|
115
|
+
if kind == "rename":
|
|
116
|
+
old = item.get("oldUri", "?")
|
|
117
|
+
new = item.get("newUri", "?")
|
|
118
|
+
return f"rename {old} -> {new}"
|
|
119
|
+
uri = item.get("uri", "?")
|
|
120
|
+
return f"{kind} {uri}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _edit_sort_key(edit: dict[str, Any]) -> tuple[int, int]:
|
|
124
|
+
"""Sort key from a TextEdit's ``range.start`` (0-based line, character)."""
|
|
125
|
+
range_ = edit.get("range") or {}
|
|
126
|
+
start = range_.get("start") if isinstance(range_, dict) else None
|
|
127
|
+
if not isinstance(start, dict):
|
|
128
|
+
return (0, 0)
|
|
129
|
+
line = int(start.get("line", 0) or 0)
|
|
130
|
+
char = int(start.get("character", 0) or 0)
|
|
131
|
+
return (line, char)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _utf16_units_to_py_col(line_text: str, utf16_char: int) -> int:
|
|
135
|
+
"""Map a UTF-16 code-unit offset into a line to a Python str column index.
|
|
136
|
+
|
|
137
|
+
LSP ``Position.character`` is counted in **UTF-16 code units**, not Python
|
|
138
|
+
code points. multilspy 0.0.15 does not negotiate ``positionEncoding`` with
|
|
139
|
+
the server, so the default (UTF-16) is what every server uses. A non-BMP
|
|
140
|
+
character (``ord(ch) > 0xFFFF`` — emoji, astral plane) is a surrogate pair
|
|
141
|
+
occupying *two* UTF-16 units but a *single* Python str index. Treating the
|
|
142
|
+
character offset as a Python index therefore shifts every position after an
|
|
143
|
+
astral character left by one per astral char, silently corrupting the edit
|
|
144
|
+
range and the file.
|
|
145
|
+
|
|
146
|
+
We walk the line accumulating UTF-16 units until we reach ``utf16_char`` and
|
|
147
|
+
return the corresponding Python column. A ``utf16_char`` that lands in the
|
|
148
|
+
middle of a surrogate pair, or runs past the line's UTF-16 length, clamps to
|
|
149
|
+
the line end — matching the boundary-clamp safety of the offset path.
|
|
150
|
+
|
|
151
|
+
(The read-only ``lsp_definition`` / ``lsp_references`` coordinates in
|
|
152
|
+
``coord.py`` pass ``character`` through unconverted; that only affects
|
|
153
|
+
display, so the small risk is out of scope here — only the write path can
|
|
154
|
+
corrupt a file.)
|
|
155
|
+
"""
|
|
156
|
+
if utf16_char <= 0:
|
|
157
|
+
return 0
|
|
158
|
+
units = 0
|
|
159
|
+
for col, ch in enumerate(line_text):
|
|
160
|
+
if units >= utf16_char:
|
|
161
|
+
return col
|
|
162
|
+
units += 2 if ord(ch) > 0xFFFF else 1
|
|
163
|
+
return len(line_text)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _offset_for_position(
|
|
167
|
+
line_starts: list[int],
|
|
168
|
+
lines: list[str],
|
|
169
|
+
line: int,
|
|
170
|
+
char: int,
|
|
171
|
+
text_len: int,
|
|
172
|
+
) -> int:
|
|
173
|
+
"""Convert a 0-based ``(line, character)`` to an absolute string offset.
|
|
174
|
+
|
|
175
|
+
``line_starts[i]`` is the offset where line ``i`` begins and ``lines[i]`` is
|
|
176
|
+
that line's text (terminator stripped) used to translate the UTF-16
|
|
177
|
+
``character`` into a Python column. Positions past the end of a line / file
|
|
178
|
+
are clamped to the file length so a malformed range from the server can
|
|
179
|
+
never raise — it just edits at the boundary.
|
|
180
|
+
"""
|
|
181
|
+
if not line_starts:
|
|
182
|
+
return 0
|
|
183
|
+
if line < 0:
|
|
184
|
+
line = 0
|
|
185
|
+
if line >= len(line_starts):
|
|
186
|
+
return text_len
|
|
187
|
+
line_text = lines[line] if line < len(lines) else ""
|
|
188
|
+
py_col = _utf16_units_to_py_col(line_text, char)
|
|
189
|
+
return min(line_starts[line] + py_col, text_len)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _build_line_starts(text: str) -> list[int]:
|
|
193
|
+
"""Return the absolute offset at which each line begins.
|
|
194
|
+
|
|
195
|
+
Uses the same line model the LSP spec implies: a line ends at (and includes)
|
|
196
|
+
its terminator, and the next line starts immediately after. The final
|
|
197
|
+
sentinel lets a position on the last line resolve even when the file has no
|
|
198
|
+
trailing newline.
|
|
199
|
+
"""
|
|
200
|
+
starts = [0]
|
|
201
|
+
for index, ch in enumerate(text):
|
|
202
|
+
if ch == "\n":
|
|
203
|
+
starts.append(index + 1)
|
|
204
|
+
return starts
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _build_lines(text: str) -> list[str]:
|
|
208
|
+
"""Split ``text`` into per-line content for UTF-16 column translation.
|
|
209
|
+
|
|
210
|
+
Aligned with :func:`_build_line_starts`: one entry per line, split on
|
|
211
|
+
``\\n``. A trailing ``\\r`` is kept (CRLF files) so UTF-16 unit counting
|
|
212
|
+
matches the bytes actually on the line; the splice still uses absolute
|
|
213
|
+
offsets, so the terminator is never disturbed.
|
|
214
|
+
"""
|
|
215
|
+
return text.split("\n")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def apply_text_edits(text: str, edits: list[dict[str, Any]]) -> str:
|
|
219
|
+
"""Apply a list of LSP ``TextEdit`` objects to ``text`` and return the result.
|
|
220
|
+
|
|
221
|
+
Edits are applied bottom-up (sorted by start position descending) so the
|
|
222
|
+
character offsets computed for earlier edits remain valid while later ones
|
|
223
|
+
are spliced in. This yields the same result as applying every edit against
|
|
224
|
+
the original document simultaneously, which is the LSP contract for a
|
|
225
|
+
single ``TextEdit[]`` (the spec forbids overlapping ranges).
|
|
226
|
+
"""
|
|
227
|
+
line_starts = _build_line_starts(text)
|
|
228
|
+
lines = _build_lines(text)
|
|
229
|
+
text_len = len(text)
|
|
230
|
+
# Descending by start position: apply the last edit in the file first so
|
|
231
|
+
# splicing it never shifts offsets of edits that come earlier.
|
|
232
|
+
ordered = sorted(edits, key=_edit_sort_key, reverse=True)
|
|
233
|
+
result = text
|
|
234
|
+
for edit in ordered:
|
|
235
|
+
range_ = edit.get("range") or {}
|
|
236
|
+
start = range_.get("start") if isinstance(range_, dict) else None
|
|
237
|
+
end = range_.get("end") if isinstance(range_, dict) else None
|
|
238
|
+
new_text = edit.get("newText", "")
|
|
239
|
+
if not isinstance(start, dict) or not isinstance(end, dict):
|
|
240
|
+
continue
|
|
241
|
+
start_off = _offset_for_position(
|
|
242
|
+
line_starts,
|
|
243
|
+
lines,
|
|
244
|
+
int(start.get("line", 0) or 0),
|
|
245
|
+
int(start.get("character", 0) or 0),
|
|
246
|
+
text_len,
|
|
247
|
+
)
|
|
248
|
+
end_off = _offset_for_position(
|
|
249
|
+
line_starts,
|
|
250
|
+
lines,
|
|
251
|
+
int(end.get("line", 0) or 0),
|
|
252
|
+
int(end.get("character", 0) or 0),
|
|
253
|
+
text_len,
|
|
254
|
+
)
|
|
255
|
+
if end_off < start_off:
|
|
256
|
+
start_off, end_off = end_off, start_off
|
|
257
|
+
result = result[:start_off] + str(new_text) + result[end_off:]
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def apply_workspace_edit(workspace_edit: dict[str, Any]) -> WorkspaceEditResult:
|
|
262
|
+
"""Apply a full ``WorkspaceEdit`` to disk and return a summary.
|
|
263
|
+
|
|
264
|
+
Parses both ``changes`` and ``documentChanges`` forms, groups the
|
|
265
|
+
``TextEdit`` entries by URI, applies each group bottom-up, and writes the
|
|
266
|
+
result atomically. Resource operations (CreateFile / RenameFile /
|
|
267
|
+
DeleteFile) are skipped and reported. A URI that resolves to a non-``file:``
|
|
268
|
+
target, or whose file cannot be read, is skipped with a note rather than
|
|
269
|
+
raising — the caller turns an empty result into an explicit error.
|
|
270
|
+
"""
|
|
271
|
+
result = WorkspaceEditResult()
|
|
272
|
+
groups = _iter_edit_groups(workspace_edit, result.skipped)
|
|
273
|
+
|
|
274
|
+
for uri, edits in groups.items():
|
|
275
|
+
if not edits:
|
|
276
|
+
continue
|
|
277
|
+
path = document_uri_to_path(uri)
|
|
278
|
+
if path.startswith("file:") or "://" in path:
|
|
279
|
+
# document_uri_to_path returns non-``file:`` URIs unchanged; we
|
|
280
|
+
# cannot write those (untitled / virtual docs).
|
|
281
|
+
result.skipped.append(f"unsupported document URI: {uri}")
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
with open(path, encoding="utf-8", newline="") as handle:
|
|
285
|
+
original = handle.read()
|
|
286
|
+
except OSError as exc:
|
|
287
|
+
result.skipped.append(f"could not read {path}: {exc}")
|
|
288
|
+
continue
|
|
289
|
+
updated = apply_text_edits(original, edits)
|
|
290
|
+
if updated != original:
|
|
291
|
+
atomic_write_text_path(path, updated)
|
|
292
|
+
result.files[path] = len(edits)
|
|
293
|
+
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def atomic_write_text_path(path: str, text: str) -> None:
|
|
298
|
+
"""Thin shim so :func:`apply_workspace_edit` can write a ``str`` path.
|
|
299
|
+
|
|
300
|
+
:func:`bareagent.core.fileutil.atomic_write_text` takes a ``Path``; constructing
|
|
301
|
+
it here keeps the import surface of this module to one helper.
|
|
302
|
+
"""
|
|
303
|
+
from pathlib import Path
|
|
304
|
+
|
|
305
|
+
atomic_write_text(Path(path), text)
|